playsocketjs 2.6.4 → 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
  ```
@@ -216,6 +217,26 @@ new PlaySocket(options: PlaySocketServerOptions)
216
217
  | `server` | http.Server | - | Existing http server (optional) |
217
218
  | `rateLimit` | number | 20 | Adjust the messages/second rate limit |
218
219
  | `debug` | boolean | false | Set to true to enable extra logging |
220
+ | `verifyClient` | function | - | Optional callback to verify connections before WebSocket upgrade |
221
+
222
+ #### verifyClient callback
223
+
224
+ The `verifyClient` option allows you to implement custom connection verification logic, such as rate limiting, before the WebSocket handshake completes.
225
+
226
+ ```javascript
227
+ const server = new PlaySocketServer({
228
+ server: httpServer,
229
+ path: '/socket',
230
+ verifyClient: (info, callback) => {
231
+ // info.req - the HTTP request object, info.origin - the Origin header value
232
+ const ip = info.req.headers['x-forwarded-for'] || info.req.socket.remoteAddress;
233
+ if (isRateLimited(ip)) return callback(false, 429, 'Too Many Requests');
234
+ callback(true);
235
+ }
236
+ });
237
+ ```
238
+
239
+ The callback signature is `callback(verified, code?, message?)` where `code` and `message` are the optional HTTP response status for rejected connections.
219
240
 
220
241
  ### Methods
221
242
 
@@ -223,10 +244,11 @@ new PlaySocket(options: PlaySocketServerOptions)
223
244
  |--------|------------|-------------|-------------|
224
245
  | `stop()` | - | `void` | Closes all active client connections, the websocket server and the underlying http server if it's standalone |
225
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 |
226
248
  | `onEvent()` | `event: string, callback: Function` | `void` | Register a server-side event callback |
227
249
  | `getRoomStorage()` | `roomId: string` | `object` | Get a snapshot of the current room storage |
228
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 |
229
- | `createRoom()` | `initialStorage?: object, size?: number, host?: string` | `object` | Create a room (returns object containing room ID and state) – Rooms created with a non-player host like "server" (default) will not be deleted when the last participant leaves |
251
+ | `createRoom()` | `initialStorage?: object, size?: number, host?: string` | `object` | Create a room (returns object containing room ID and state)|
230
252
  | `destroyRoom()` | `roomId: string` | `void` | Destroy a room & kick all participants |
231
253
 
232
254
  ### Event types
@@ -235,11 +257,11 @@ new PlaySocket(options: PlaySocketServerOptions)
235
257
  |-------|-------------------|-------------|--------------|
236
258
  | `clientRegistered` | `clientId: string, customData: object` | Client registered with the server | - |
237
259
  | `clientRegistrationRequested` | `clientId: string, customData: object` | Client requests to register | Return `false` or rejection reason `string` to block |
238
- | `clientDisconnected` | `clientId: string` | Client disconnected from the server | - |
260
+ | `clientLeft` | `clientId: string` | Client disconnected from the server | - |
239
261
  | `clientJoinedRoom` | `clientId: string, roomId: string` | Client joined a room (clients can only leave by disconnecting) | - |
240
262
  | `clientJoinRequested` | `clientId: string, roomId: string` | Client requests to join a room | Return `false` or rejection reason `string` to block |
241
263
  | `roomCreated` | `roomId: string` | Client created a room | - |
242
- | `roomDestroyed` | `roomId: string` | Room was destroyed (happens when all participants leave) | - |
264
+ | `roomDestroyed` | `roomId: string` | Room was destroyed (happens when all participants leave, unless room host is "server") | - |
243
265
  | `roomCreationRequested` | `{clientId: string, initialStorage: object}` | Room creation requested by client | Return `object` to override initial storage, `false` to deny |
244
266
  | `storageUpdated` | `{clientId: string, roomId: string, update: object, storage: object}` | Room storage property updated | - |
245
267
  | `storageUpdateRequested` | `{clientId: string, roomId: string, update: object, storage: object}` | Room storage property update requested by client | Return `false` to block the update |
@@ -249,7 +271,7 @@ new PlaySocket(options: PlaySocketServerOptions)
249
271
 
250
272
  | Property | Type | Description |
251
273
  |----------|------|-------------|
252
- | `getRooms` | object | Retrieve the rooms object |
274
+ | `rooms` | object | Retrieve the rooms object |
253
275
 
254
276
  # License
255
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
@@ -2119,9 +2156,10 @@ class PlaySocketServer {
2119
2156
  * @param {string} [options.path='/'] - WebSocket endpoint path
2120
2157
  * @param {boolean} [options.debug=false] - Enable debug logging
2121
2158
  * @param {number} [options.rateLimit=20] - Maximum number of operations per second per client
2159
+ * @param {Function} [options.verifyClient] - Optional callback to verify client connections before upgrade. Receives (info, callback) where info contains { req, origin } and callback is (verified, code?, message?) => void
2122
2160
  */
2123
2161
  constructor(options = {}) {
2124
- const { server, port = 3000, path = '/', debug = false, rateLimit = 20 } = options;
2162
+ const { server, port = 3000, path = '/', debug = false, rateLimit = 20, verifyClient } = options;
2125
2163
 
2126
2164
  if (debug) this.#debug = true; // Enable extra logging
2127
2165
 
@@ -2141,7 +2179,8 @@ class PlaySocketServer {
2141
2179
  // Create WebSocket server
2142
2180
  this.#wss = new WebSocketServer({
2143
2181
  server: this.#server,
2144
- path
2182
+ path,
2183
+ ...(verifyClient && { verifyClient })
2145
2184
  });
2146
2185
 
2147
2186
  // Set up ws event handlers
@@ -2168,7 +2207,7 @@ class PlaySocketServer {
2168
2207
  ws.isAlive = false;
2169
2208
  ws.ping();
2170
2209
  });
2171
- }, 15000);
2210
+ }, HEARTBEAT_INTERVAL);
2172
2211
  }
2173
2212
 
2174
2213
  /**
@@ -2260,9 +2299,9 @@ class PlaySocketServer {
2260
2299
  // If the room has no host (they were the only one and disconnected), restore them as host
2261
2300
  if (formerRoom.host === null) formerRoom.host = data.id;
2262
2301
 
2263
- 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);
2264
2303
  roomData = {
2265
- state: formerRoom.crdtManager.getState,
2304
+ state: formerRoom.crdtManager.state,
2266
2305
  participantCount: formerRoom.participants.length,
2267
2306
  host: formerRoom.host,
2268
2307
  version: this.#roomVersions.get(formerRoomId)
@@ -2304,7 +2343,7 @@ class PlaySocketServer {
2304
2343
 
2305
2344
  this.#rooms[newRoom.id].participants.push(ws.clientId); // Add client to the room
2306
2345
  this.#clientRooms.set(ws.clientId, newRoom.id); // Add client to the client-room map
2307
- 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 });
2308
2347
  } catch (error) {
2309
2348
  console.error("PlaySocket error creating room:", error);
2310
2349
  ws.send(encode({ type: 'room_creation_failed', reason: error.message }), { binary: true });
@@ -2315,7 +2354,6 @@ class PlaySocketServer {
2315
2354
  case 'join_room': {
2316
2355
  if (!data.roomId || !ws.clientId) return;
2317
2356
  const roomId = data.roomId;
2318
- const room = this.#rooms[roomId];
2319
2357
 
2320
2358
  const rejectJoin = (reason) => {
2321
2359
  ws.send(encode({ type: 'join_rejected', reason }), { binary: true });
@@ -2325,47 +2363,12 @@ class PlaySocketServer {
2325
2363
  const joinAllowed = await this.#triggerEvent("clientJoinRequested", ws.clientId, roomId);
2326
2364
  if (joinAllowed === false || typeof joinAllowed === 'string') return rejectJoin(typeof joinAllowed === 'string' ? joinAllowed : 'Denied.');
2327
2365
 
2328
- if (!room) return rejectJoin("Room not found.");
2329
- if (this.#clientRooms.get(ws.clientId)) return rejectJoin("Already in a room.");
2330
- if (room.participants.length >= room.size) return rejectJoin("Room full.");
2331
-
2332
- room.participants.push(ws.clientId);
2333
- this.#clientRooms.set(ws.clientId, roomId);
2334
-
2335
- // Notify joiner with initial storage
2336
- if (this.#debug) console.log("Room state sent for join:", room.crdtManager.getState);
2337
- ws.send(encode({
2338
- type: 'join_accepted',
2339
- state: room.crdtManager.getState,
2340
- participantCount: room.participants.length,
2341
- host: room.host,
2342
- version: this.#roomVersions.get(roomId)
2343
- }), { binary: true });
2344
-
2345
- // If the room has no host (previous host disconnected and was the only participant), make joiner the host
2346
- // Since the room had to be empty, only the joiner needs to be notified
2347
- const becameHost = room.host === null;
2348
- if (becameHost) {
2349
- room.host = ws.clientId;
2350
- ws.send(encode({
2351
- type: 'host_migrated',
2352
- newHost: room.host,
2353
- }), { binary: true });
2366
+ try {
2367
+ this.#joinRoom(ws.clientId, roomId);
2368
+ } catch (error) {
2369
+ rejectJoin(error.message);
2354
2370
  }
2355
2371
 
2356
- // Notify existing participants
2357
- room.participants.forEach(p => {
2358
- if (p === ws.clientId) return;
2359
- const client = this.#clients.get(p);
2360
- if (client) {
2361
- client.send(encode({
2362
- type: 'client_connected',
2363
- client: ws.clientId,
2364
- participantCount: room.participants.length
2365
- }), { binary: true });
2366
- }
2367
- });
2368
- this.#triggerEvent("clientJoinedRoom", ws.clientId, roomId);
2369
2372
  break;
2370
2373
  }
2371
2374
 
@@ -2379,7 +2382,7 @@ class PlaySocketServer {
2379
2382
  if (updateAllowed === false) {
2380
2383
  ws.send(encode({
2381
2384
  type: 'property_update_rejected',
2382
- state: room.crdtManager.getState
2385
+ state: room.crdtManager.state
2383
2386
  }), { binary: true });
2384
2387
  return;
2385
2388
  }
@@ -2434,29 +2437,28 @@ class PlaySocketServer {
2434
2437
  }
2435
2438
 
2436
2439
  /**
2437
- * If the client that disconnected (and is now in the reconnection-phase) was the room host,
2438
- * pick a new room host immediately to avoid host-less phase (in case important logic is attached to the host)
2439
- * @param {string} roomId - ID of the room that the disconnected client was in
2440
- * @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
2441
2443
  */
2442
- #changeHostIfDisconnected(roomId, clientId) {
2444
+ #migrateHost(roomId, clientId) {
2443
2445
  const room = this.#rooms[roomId];
2444
- if (room && room.host === clientId) {
2445
- const participantsWithoutClient = room.participants.filter((e) => e !== clientId);
2446
-
2447
- if (participantsWithoutClient.length >= 1) {
2448
- room.host = participantsWithoutClient[0]; // Set new host
2449
-
2450
- // Inform all participants about the new host
2451
- participantsWithoutClient.forEach(p => {
2452
- const client = this.#clients.get(p);
2453
- if (client) client.send(encode({
2454
- type: 'host_migrated',
2455
- newHost: room.host,
2456
- }), { binary: true });
2457
- });
2458
- } else room.host = null; // The next person to join will become the host
2459
- }
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
2460
2462
  }
2461
2463
 
2462
2464
  /**
@@ -2512,16 +2514,15 @@ class PlaySocketServer {
2512
2514
 
2513
2515
  // If client was in a room, check if they were the host & migrate
2514
2516
  const roomId = this.#clientRooms.get(ws.clientId);
2515
- if (roomId != null) this.#changeHostIfDisconnected(roomId, ws.clientId);
2517
+ if (roomId != null && this.#rooms[roomId]?.host === ws.clientId) this.#migrateHost(roomId, ws.clientId);
2516
2518
 
2517
- if (ws.willfulDisconnect) {
2518
- this.#disconnectClient(ws); // Immediate disconnection
2519
- } else {
2519
+ if (ws.willfulDisconnect) this.#disconnectClient(ws); // Immediate disconnection
2520
+ else {
2520
2521
  // Pending complete disconnection with 5s grace period to allow for reconnections
2521
2522
  this.#pendingDisconnects.set(ws.clientId, {
2522
2523
  timeout: setTimeout(() => {
2523
2524
  this.#disconnectClient(ws);
2524
- }, 5000)
2525
+ }, RECONNECT_GRACE_PERIOD)
2525
2526
  });
2526
2527
  }
2527
2528
  }
@@ -2535,29 +2536,8 @@ class PlaySocketServer {
2535
2536
  #disconnectClient(ws) {
2536
2537
  this.#pendingDisconnects.delete(ws.clientId);
2537
2538
  this.#clientTokens.delete(ws.clientId);
2538
- let roomId = this.#clientRooms.get(ws.clientId);
2539
- const room = this.#rooms[roomId];
2540
-
2541
- if (room) {
2542
- room.participants = room.participants.filter(p => p !== ws.clientId); // Remove client from room
2543
- this.#clientRooms.delete(ws.clientId);
2544
-
2545
- if (room.participants?.length === 0 && room.host !== "server") {
2546
- this.destroyRoom(roomId); // Destroy room if now empty & not created by server
2547
- roomId = null; // If room was destroyed, set roomId to null
2548
- } else {
2549
- // Notify remaining participants
2550
- room.participants.forEach(p => {
2551
- const client = this.#clients.get(p);
2552
- if (client) client.send(encode({
2553
- type: 'client_disconnected',
2554
- client: ws.clientId,
2555
- participantCount: room.participants.length
2556
- }), { binary: true });
2557
- });
2558
- }
2559
- }
2560
-
2539
+ const roomId = this.#clientRooms.get(ws.clientId);
2540
+ if (roomId && this.#rooms[roomId]) this.#leaveRoom(ws.clientId, roomId);
2561
2541
  this.#triggerEvent("clientDisconnected", ws.clientId, roomId);
2562
2542
  }
2563
2543
 
@@ -2576,17 +2556,37 @@ class PlaySocketServer {
2576
2556
  }
2577
2557
 
2578
2558
  /**
2579
- * Kick a player from the server
2559
+ * Kick a client from the server
2580
2560
  * @param {string} clientId - Client ID
2581
2561
  * @param {string} [reason] - Optional reason
2582
2562
  */
2583
2563
  kick(clientId, reason) {
2584
2564
  const client = this.#clients.get(clientId);
2585
- if (client) {
2586
- client.willfulDisconnect = true;
2587
- client.send(encode({ type: 'kicked', reason }), { binary: true });
2588
- client.close();
2589
- }
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);
2590
2590
  }
2591
2591
 
2592
2592
  /**
@@ -2618,7 +2618,7 @@ class PlaySocketServer {
2618
2618
  */
2619
2619
  getRoomStorage(roomId) {
2620
2620
  const room = this.#rooms[roomId];
2621
- if (room) return room.crdtManager.getPropertyStore;
2621
+ if (room) return room.crdtManager.propertyStore;
2622
2622
  }
2623
2623
 
2624
2624
  /**
@@ -2652,7 +2652,7 @@ class PlaySocketServer {
2652
2652
  }
2653
2653
 
2654
2654
  /**
2655
- * Create a room from the server
2655
+ * Create a room
2656
2656
  * @param {object} [initialStorage] - Optional initial storage object
2657
2657
  * @param {number} [size] - Max. room size, up to 500
2658
2658
  * @param {string} [host] - Host ID, defaults to "server" (when set to "server", room will not be deleted if all clients leave)
@@ -2669,7 +2669,7 @@ class PlaySocketServer {
2669
2669
  }
2670
2670
  }
2671
2671
 
2672
- if (!newRoomId) throw new Error("No available ID found.")
2672
+ if (!newRoomId) throw new Error("No available ID found.");
2673
2673
  const roomCrdtManager = new CRDTManager(this.#debug);
2674
2674
 
2675
2675
  if (initialStorage) Object.entries(initialStorage)?.forEach(([key, value]) => {
@@ -2686,7 +2686,82 @@ class PlaySocketServer {
2686
2686
 
2687
2687
  this.#triggerEvent("roomCreated", newRoomId);
2688
2688
  if (this.#debug) console.log(`Room ${newRoomId} created with initial storage:`, initialStorage);
2689
- 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
+ }
2690
2765
  }
2691
2766
 
2692
2767
  /**
@@ -2695,9 +2770,9 @@ class PlaySocketServer {
2695
2770
  */
2696
2771
  destroyRoom(roomId) {
2697
2772
  const room = this.#rooms[roomId];
2698
- if (!room) return; // Return if room does not exist
2773
+ if (!room) throw new Error("Room not found.");
2699
2774
 
2700
- // Disconnect participants if still in room
2775
+ // Disconnect clients if still in room
2701
2776
  const participants = room.participants;
2702
2777
  participants.forEach((clientId) => {
2703
2778
  this.kick(clientId, "Room destroyed by server.");
@@ -2725,7 +2800,7 @@ class PlaySocketServer {
2725
2800
  });
2726
2801
  }
2727
2802
 
2728
- get getRooms() { return { ...this.#rooms } }
2803
+ get rooms() { return structuredClone(this.#rooms) }
2729
2804
  }
2730
2805
 
2731
- 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.6.4",
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
+ }