playsocketjs 2.7.0 → 3.0.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 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.getStorage; // Synchronous, local access
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 | `none - set this!` | WebSocket server endpoint (e.g., 'wss://example.com/socket') |
96
- | `customData` | object | `{}` | Arbitrary data to pass to the "clientRegistered" server event (optional) |
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 Promise which resolves with the client's ID |
104
- | `createRoom()` | `initialStorage?: object, size?: number` | `Promise<string>` | Create a new room and become host – Returns Promise which resolves with the room ID. The room participant maximum is 100 |
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 these in the `requestReceived` server event) |
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
- | `clientConnected` | `clientId: string` | New client connected to the room |
121
- | `clientDisconnected` | `clientId: string, roomId?: string` | Client disconnected from the room |
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
- | `connectionCount` | number | Number of active client connections in room (without yourself) |
130
- | `getStorage` | object | Retrieve storage object |
130
+ | `participantCount` | number | Number of active client connections in room (with yourself) |
131
+ | `storage` | object | Retrieve storage object |
131
132
 
132
133
  &nbsp;
133
134
 
@@ -160,7 +161,7 @@ function shutdown() {
160
161
  process.exit(0);
161
162
  }
162
163
 
163
- // Handle both SIGINT (Ctrl+C) and SIGTERM (Docker stop)
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 (Ctrl+C) and SIGTERM (Docker stop)
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 |
@@ -255,7 +257,7 @@ The callback signature is `callback(verified, code?, message?)` where `code` and
255
257
  |-------|-------------------|-------------|--------------|
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
- | `clientDisconnected` | `clientId: string` | Client disconnected from the server | - |
260
+ | `clientLeft` | `clientId: string` | Client disconnected from the server | - |
259
261
  | `clientJoinedRoom` | `clientId: string, roomId: string` | Client joined a room (clients can only leave by disconnecting) | - |
260
262
  | `clientJoinRequested` | `clientId: string, roomId: string` | Client requests to join a room | Return `false` or rejection reason `string` to block |
261
263
  | `roomCreated` | `roomId: string` | Client created a room | - |
@@ -269,7 +271,7 @@ The callback signature is `callback(verified, code?, message?)` where `code` and
269
271
 
270
272
  | Property | Type | Description |
271
273
  |----------|------|-------------|
272
- | `getRooms` | object | Retrieve the rooms object |
274
+ | `rooms` | object | Retrieve the rooms object |
273
275
 
274
276
  # License
275
277
 
@@ -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
- constructor() {
961
- this.stack = [];
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 decodeStream}, or {@link decodeArrayStream}.
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
- // Custom conflict-free replicated data type system with vector clocks
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 per key, as object
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)) > MIN_AGE_FOR_GC) {
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 getPropertyStore() {
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 getState() {
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
- #connectionCount = 0;
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", "clientConnected", "clientDisconnected"];
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
- if (this.#pendingRegistration) {
2249
- this.#pendingRegistration.reject(new Error("Failed to register: " + message.reason));
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
- if (this.#pendingRegistration) {
2256
- this.#sessionToken = message.sessionToken;
2257
- this.#initialized = true;
2258
- this.#pendingRegistration.resolve(message.id);
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
- if (this.#debug) console.log(LOG_PREFIX + "State received for join:", message.state);
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
- if (this.#pendingJoin) {
2281
- this.#triggerEvent("status", "Failed to join room: " + message.reason);
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
- if (this.#pendingReconnect) {
2289
- this.#isReconnecting = false;
2290
- this.#reconnectCount = 0;
2291
- if (message.roomData) {
2292
- if (this.#debug) console.log(LOG_PREFIX + "State received for reconnect:", message.roomData.state);
2293
- this.#crdtManager.importState(message.roomData.state);
2294
- this.#roomVersion = message.roomData.version;
2295
- this.#connectionCount = message.roomData.participantCount - 1; // Counted without the user themselves
2296
- this.#setHost(message.roomData.host); // Set host before in case there are .isHost checks in the storageUpdate fallback
2297
- this.#triggerEvent("storageUpdated", this.getStorage);
2298
- } else if (this.#inRoom) {
2299
- // If no room data received, but client thinks they were in a room...
2300
- this.#triggerEvent("error", "Reconnected, but room no longer exists.");
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
- if (this.#pendingCreate) {
2314
- this.#inRoom = true;
2315
- this.#triggerEvent("status", `Room created.`);
2316
- this.#crdtManager.importState(message.state);
2317
- this.#triggerEvent("storageUpdated", this.getStorage);
2318
- this.#pendingCreate.resolve(message.roomId);
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
- if (this.#pendingCreate) {
2324
- this.#triggerEvent("error", "Failed to create room: " + message.reason);
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.getStorage);
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.getStorage);
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 'client_disconnected':
2356
- this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
2357
- this.#triggerEvent("clientDisconnected", message.client);
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 'client_connected':
2362
- this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
2363
- this.#triggerEvent("clientConnected", message.client);
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.getStorage); // Always trigger callback AFTER send in case msgs are sent in the callback (which would break the order)
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.#connectionCount = 0;
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 connectionCount() { return this.#connectionCount; }
2581
- get getStorage() { return this.#crdtManager.getPropertyStore; }
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
- constructor() {
964
- this.stack = [];
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 decodeStream}, or {@link decodeArrayStream}.
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
- // Custom conflict-free replicated data type system with vector clocks
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 per key, as object
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)) > MIN_AGE_FOR_GC) {
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 getPropertyStore() {
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 getState() {
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
- }, 15000);
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.getState);
2302
+ if (this.#debug) console.log(`State sent for reconnection for room ${formerRoomId}:`, formerRoom.crdtManager.state);
2266
2303
  roomData = {
2267
- state: formerRoom.crdtManager.getState,
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
- if (!room) return rejectJoin("Room not found.");
2331
- if (this.#clientRooms.get(ws.clientId)) return rejectJoin("Already in a room.");
2332
- if (room.participants.length >= room.size) return rejectJoin("Room full.");
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.getState
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
- * 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 - ID of the room that the disconnected client was in
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
- #changeHostIfDisconnected(roomId, clientId) {
2444
+ #migrateHost(roomId, clientId) {
2445
2445
  const room = this.#rooms[roomId];
2446
- if (room && room.host === clientId) {
2447
- const participantsWithoutClient = room.participants.filter((e) => e !== clientId);
2448
-
2449
- if (participantsWithoutClient.length >= 1) {
2450
- room.host = participantsWithoutClient[0]; // Set new host
2451
-
2452
- // Inform all participants about the new host
2453
- participantsWithoutClient.forEach(p => {
2454
- const client = this.#clients.get(p);
2455
- if (client) client.send(encode({
2456
- type: 'host_migrated',
2457
- newHost: room.host,
2458
- }), { binary: true });
2459
- });
2460
- } else room.host = null; // The next person to join will become the host
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.#changeHostIfDisconnected(roomId, ws.clientId);
2517
+ if (roomId != null && this.#rooms[roomId]?.host === ws.clientId) this.#migrateHost(roomId, ws.clientId);
2518
2518
 
2519
- if (ws.willfulDisconnect) {
2520
- this.#disconnectClient(ws); // Immediate disconnection
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
- }, 5000)
2525
+ }, RECONNECT_GRACE_PERIOD)
2527
2526
  });
2528
2527
  }
2529
2528
  }
@@ -2537,29 +2536,8 @@ class PlaySocketServer {
2537
2536
  #disconnectClient(ws) {
2538
2537
  this.#pendingDisconnects.delete(ws.clientId);
2539
2538
  this.#clientTokens.delete(ws.clientId);
2540
- let roomId = this.#clientRooms.get(ws.clientId);
2541
- const room = this.#rooms[roomId];
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
-
2539
+ const roomId = this.#clientRooms.get(ws.clientId);
2540
+ if (roomId && this.#rooms[roomId]) this.#leaveRoom(ws.clientId, roomId);
2563
2541
  this.#triggerEvent("clientDisconnected", ws.clientId, roomId);
2564
2542
  }
2565
2543
 
@@ -2578,17 +2556,37 @@ class PlaySocketServer {
2578
2556
  }
2579
2557
 
2580
2558
  /**
2581
- * Kick a player from the server
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
- client.willfulDisconnect = true;
2589
- client.send(encode({ type: 'kicked', reason }), { binary: true });
2590
- client.close();
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.getPropertyStore;
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 from the server
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,82 @@ 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.getState, id: newRoomId };
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
+
2752
+ if (room.participants.length === 0 && room.host !== "server") {
2753
+ this.destroyRoom(roomId); // Destroy room if now empty & not by server
2754
+ } else {
2755
+ // Notify remaining participants
2756
+ room.participants.forEach(p => {
2757
+ const client = this.#clients.get(p);
2758
+ if (client) client.send(encode({
2759
+ type: 'client_left',
2760
+ client: clientId,
2761
+ participantCount: room.participants.length
2762
+ }), { binary: true });
2763
+ });
2764
+ }
2692
2765
  }
2693
2766
 
2694
2767
  /**
@@ -2697,9 +2770,9 @@ class PlaySocketServer {
2697
2770
  */
2698
2771
  destroyRoom(roomId) {
2699
2772
  const room = this.#rooms[roomId];
2700
- if (!room) return; // Return if room does not exist
2773
+ if (!room) throw new Error("Room not found.");
2701
2774
 
2702
- // Disconnect participants if still in room
2775
+ // Disconnect clients if still in room
2703
2776
  const participants = room.participants;
2704
2777
  participants.forEach((clientId) => {
2705
2778
  this.kick(clientId, "Room destroyed by server.");
@@ -2727,7 +2800,7 @@ class PlaySocketServer {
2727
2800
  });
2728
2801
  }
2729
2802
 
2730
- get getRooms() { return { ...this.#rooms } }
2803
+ get rooms() { return structuredClone(this.#rooms) }
2731
2804
  }
2732
2805
 
2733
- export { PlaySocketServer as default };
2806
+ export { RECONNECT_GRACE_PERIOD, PlaySocketServer as default };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playsocketjs",
3
- "version": "2.7.0",
3
+ "version": "3.0.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": "echo \"Error: no test specified\" && exit 1",
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
- "rollup": "^4.31.0",
46
- "globals": "^16.3.0"
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
+ }