playsocketjs 5.3.2 → 6.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
@@ -127,7 +127,7 @@ new PlaySocket(id?: string, options: PlaySocketOptions)
127
127
  | Name | Parameters | Return type | Description |
128
128
  |--------|------------|-------------|-------------|
129
129
  | `init()` | - | `Promise<string>` | Initialize the WebSocket connection, resolves with the client ID. |
130
- | `createRoom()` | `initialStorage?: object, size?: number` | `Promise<string>` | Create a new room, resolves with the room ID. Max. 500 participants. |
130
+ | `createRoom()` | `initialStorage?: object` | `Promise<string>` | Create a new room, resolves with the room ID. |
131
131
  | `joinRoom()` | `roomId: string` | `Promise<void>` | Join an existing room. |
132
132
  | `destroy()` | `reason?: string` | `void` | Leave room, close the connection, and destroy the instance. |
133
133
  | `updateStorage()` | `key: string, type: string, value: any, secondValue?: any` | `void` | Update a key in the shared storage. |
@@ -243,6 +243,16 @@ server.onEvent("requestReceived", async ({ roomId, clientId, name, data }) => {
243
243
  });
244
244
  ```
245
245
 
246
+ Limiting the room size:
247
+
248
+ ```javascript
249
+ const server = new PlaySocketServer();
250
+
251
+ server.onEvent("clientJoinRequested", (clientId, roomId) => {
252
+ if (server.rooms[roomId].participants.length >= 10) return "Room full";
253
+ });
254
+ ```
255
+
246
256
  Combining PlaySocket with a custom authentication system:
247
257
 
248
258
  ```javascript
@@ -253,12 +263,16 @@ server.onEvent("clientRegistrationRequested", async (clientId, data) => {
253
263
  try {
254
264
  // Your custom auth logic...
255
265
  // For example, data could contain a token
256
- authedClients.push(clientId);
257
266
  } catch (error) {
258
267
  return "Error occurred during auth"; // Blocks the registration
259
268
  }
260
269
  });
261
270
 
271
+ // On successful registration, mark the client as authed
272
+ server.onEvent("clientRegistered", (clientId, data) => {
273
+ authedClients.push(clientId);
274
+ });
275
+
262
276
  server.onEvent("clientDisconnected", async (clientId) => {
263
277
  const removeIndex = authedClients.indexOf(clientId);
264
278
  if (removeIndex !== -1) authedClients.splice(removeIndex, 1);
@@ -308,18 +322,18 @@ The callback signature is `callback(verified, code?, message?)` where `code` ref
308
322
  #### Methods
309
323
 
310
324
  > [!IMPORTANT]
311
- > Rooms created by the server are always owned by the host ID "server". Such rooms are not auto-deleted when all participants have left, so you need to take care of their lifecycle.
325
+ > Rooms created by the server have no host (`host` is null). Such rooms are not auto-deleted when all participants have left, so you need to take care of their lifecycle.
312
326
 
313
327
  | Name | Parameters | Return type | Description |
314
328
  |--------|------------|-------------|-------------|
315
- | `stop()` | - | `void` | Closes active client connections and the underlying http server (if it's standalone). |
329
+ | `stop()` | - | `void` | Closes active client connections (without triggering events) and the underlying http server (if it's standalone). |
316
330
  | `kick()` | `clientId: string, reason?: string` | `void` | Kick a client by their client ID. |
317
331
  | `move()` | `clientId: string, roomId: string` | `void` | Move a client that is already in a room to a different room. |
318
332
  | `onEvent()` | `event: string, callback: Function` | `() => void` | Register a server-side event callback. Returns unsubscribe function. |
319
333
  | `getRoomStorage()` | `roomId: string, options?: { dangerouslySkipCloning?: boolean }` | `object` | Get a snapshot of the current room storage. Pass `dangerouslySkipCloning: true` to get the uncloned store for performance reasons. |
320
334
  | `getUpdateDetails()` | `update: object` | `object` | Get the details (`key`, `type`, `value` and `secondValue`) of a storage update for building validation logic. |
321
335
  | `updateRoomStorage()` | `roomId: string, key: string, type: string, value: any, secondValue?: any` | `void` | Update a key in the shared storage of a room. |
322
- | `createRoom()` | `initialStorage?: object, size?: number` | `object` | Create a server-owned room (returns object containing room ID and state).|
336
+ | `createRoom()` | `initialStorage?: object` | `object` | Create a server-owned room (returns object containing room ID and state).|
323
337
  | `destroyRoom()` | `roomId: string` | `void` | Destroy a room & kick all participants. |
324
338
 
325
339
  #### Events
@@ -335,7 +349,7 @@ The callback signature is `callback(verified, code?, message?)` where `code` ref
335
349
  | `roomCreated` | `roomId: string` | Room was created. | - |
336
350
  | `roomDestroyed` | `roomId: string` | Room was destroyed. | - |
337
351
  | `roomCreationRequested` | `{clientId: string, initialStorage: object}` | Client requested to create room. | Return `object` to override initial storage, `false` or rejection reason `string` to block. |
338
- | `storageUpdated` | `{clientId: string, roomId: string, update: object, storage: object}` | Room storage updated. | - |
352
+ | `storageUpdated` | `{clientId: string \| null, roomId: string, update: object, storage: object}` | Room storage updated (`null` as the clientId for server-authored updates). | - |
339
353
  | `storageUpdateRequested` | `{clientId: string, roomId: string, update: object, storage: object}` | Client requested storage update. | Return `false` or rejection reason `string` to block the update. Callback must be synchronous. |
340
354
  | `requestReceived` | `{clientId: string, roomId?: string, name: string, data?: any}` | Request from client. | Return `false` or rejection reason `string` to block. |
341
355
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playsocketjs",
3
- "version": "5.3.2",
3
+ "version": "6.0.0",
4
4
  "description": "WebSocket wrapper for creating simple multiplayer systems with ease.",
5
5
  "type": "module",
6
6
  "module": "./src/client/client.js",
@@ -52,11 +52,10 @@ export default class PlaySocket {
52
52
  // Room properties
53
53
  /** @type {string | null | undefined} */
54
54
  #roomHost;
55
- /** @type {boolean | undefined} */
56
- #inRoom; // If the client is currently in a room or not
55
+ /** @type {string | null} */
56
+ #roomId = null; // ID of the room the client is currently in
57
57
  #participantCount = 0;
58
58
  #crdtManager;
59
- #roomVersion = 0; // Update version (used to compare local vs. remote state to detect package loss)
60
59
 
61
60
  // Event handling
62
61
  /** @type {Map<string, Function[]>} */
@@ -227,11 +226,10 @@ export default class PlaySocket {
227
226
 
228
227
  case "join_accepted":
229
228
  if (this.#debug) console.log(LOG_PREFIX + "State received for join:", message.state);
230
- this.#inRoom = true;
229
+ this.#roomId = message.roomId;
231
230
  this.#crdtManager.importState(message.state);
232
231
  this.#participantCount = message.participantCount;
233
232
  this.#roomHost = message.host;
234
- this.#roomVersion = message.version;
235
233
  this.#triggerEvent("storageUpdated", this.storage);
236
234
  if (this.#pendingJoin) {
237
235
  this.#triggerEvent("status", "Connected to room.");
@@ -253,13 +251,16 @@ export default class PlaySocket {
253
251
  if (message.roomData) {
254
252
  if (this.#debug) console.log(LOG_PREFIX + "State received for reconnect:", message.roomData.state);
255
253
  this.#crdtManager.importState(message.roomData.state);
256
- this.#roomVersion = message.roomData.version;
257
254
  this.#participantCount = message.roomData.participantCount;
258
255
  this.#setHost(message.roomData.host); // Set host before in case there are .isHost checks in the storageUpdate fallback
259
256
  this.#triggerEvent("storageUpdated", this.storage);
260
- } else if (this.#inRoom) {
261
- // If no room data was received, but client thinks they are in a room
262
- return this.destroy("Reconnected, but room no longer exists.");
257
+
258
+ // If the client was moved to another room while it was disconnected
259
+ if (this.#roomId && message.roomData.roomId !== this.#roomId) {
260
+ this.#triggerEvent("status", "Moved to room.");
261
+ this.#triggerEvent("moved", message.roomData.roomId);
262
+ }
263
+ this.#roomId = message.roomData.roomId;
263
264
  }
264
265
  this.#triggerEvent("status", "Reconnected.");
265
266
  if (this.#pendingReconnect) this.#pendingReconnect.resolve();
@@ -270,7 +271,7 @@ export default class PlaySocket {
270
271
  break;
271
272
 
272
273
  case "room_created":
273
- this.#inRoom = true;
274
+ this.#roomId = message.roomId;
274
275
  this.#participantCount = message.participantCount;
275
276
  this.#triggerEvent("status", "Room created.");
276
277
  this.#crdtManager.importState(message.state);
@@ -283,14 +284,9 @@ export default class PlaySocket {
283
284
  break;
284
285
 
285
286
  case "property_updated":
286
- this.#roomVersion++; // Increment room version
287
287
  if (this.#debug) console.log(LOG_PREFIX + "Property update received:", message.update);
288
288
  this.#crdtManager.importPropertyUpdate(message.update);
289
289
  if (this.#crdtManager.didPropertiesChange) this.#triggerEvent("storageUpdated", this.storage);
290
- if (this.#roomVersion != message.version && this.#initialized && this.#socket?.readyState === WebSocket.OPEN) {
291
- console.error(ERROR_PREFIX + "Detected skipped property update, forcing reconnect");
292
- this.#socket?.close();
293
- }
294
290
  break;
295
291
 
296
292
  case "property_update_rejected":
@@ -410,10 +406,9 @@ export default class PlaySocket {
410
406
  /**
411
407
  * Create a new room and become host
412
408
  * @param {object | null} [initialStorage] - Initial state
413
- * @param {number} [size] - Max number of participants
414
409
  * @returns {Promise<string>} Resolves with room ID
415
410
  */
416
- async createRoom(initialStorage, size) {
411
+ async createRoom(initialStorage) {
417
412
  if (!this.#initialized) throw new Error("Not initialized");
418
413
 
419
414
  return /** @type {Promise<string>} */ (Promise.race([
@@ -422,8 +417,7 @@ export default class PlaySocket {
422
417
  this.#pendingCreate = { resolve, reject };
423
418
  this.#sendToServer({
424
419
  type: "create_room",
425
- initialStorage,
426
- size
420
+ initialStorage
427
421
  });
428
422
  }),
429
423
  this.#createTimeout("Room creation")
@@ -465,7 +459,7 @@ export default class PlaySocket {
465
459
  * @param {*} [secondValue] - Second value (needed for some operations)
466
460
  */
467
461
  updateStorage(key, type, value, secondValue) {
468
- if (!this.#inRoom) {
462
+ if (!this.#roomId) {
469
463
  console.error(ERROR_PREFIX + "Failed to update storage, not in a room")
470
464
  return;
471
465
  };
@@ -528,11 +522,10 @@ export default class PlaySocket {
528
522
  // Reset state
529
523
  clearTimeout(this.#reconnectTimeout);
530
524
  this.#roomHost = null;
531
- this.#inRoom = false;
525
+ this.#roomId = null;
532
526
  this.#participantCount = 0;
533
527
  this.#isReconnecting = false;
534
528
  this.#reconnectCount = 0;
535
- this.#roomVersion = 0;
536
529
 
537
530
  // Reject pending promises if currently active
538
531
  if (this.#pendingJoin) this.#pendingJoin.reject(new Error("Destroyed"));
@@ -4,7 +4,6 @@ import { encode, decode } from "@msgpack/msgpack";
4
4
  import CRDTManager, { getUpdateDetails } from "../universal/crdtManager.js";
5
5
  import { HEARTBEAT_INTERVAL, VERSION, ERROR_PREFIX, WARNING_PREFIX, LOG_PREFIX } from "../universal/constants.js";
6
6
 
7
- const MAX_ROOM_SIZE = 500;
8
7
  export const RECONNECT_GRACE_PERIOD = 5000; // Exported for use in tests
9
8
 
10
9
  /**
@@ -26,8 +25,7 @@ export const RECONNECT_GRACE_PERIOD = 5000; // Exported for use in tests
26
25
  /**
27
26
  * @typedef {object} Room
28
27
  * @property {string[]} participants - Client IDs of the room participants
29
- * @property {string | null} host - Client ID of the room host ("server" for server-owned rooms, null if the room currently has no host)
30
- * @property {number} size - Max. number of participants
28
+ * @property {string | null} host - Client ID of the room host (null for server-created rooms)
31
29
  * @property {CRDTManager} crdtManager - CRDT manager holding the room storage
32
30
  */
33
31
 
@@ -70,12 +68,10 @@ export default class PlaySocketServer {
70
68
  /** @type {Map<string, Function[]>} */
71
69
  #callbacks = new Map(); // Event -> [callback functions]
72
70
  #heartbeatInterval;
73
- /** @type {Map<string, { timeout: ReturnType<typeof setTimeout> }>} */
74
- #pendingDisconnects = new Map(); // ClientId -> {timeout}
71
+ /** @type {Map<string, { timeout: ReturnType<typeof setTimeout>, onTimeout: () => void }>} */
72
+ #pendingDisconnects = new Map(); // ClientId -> {timeout, onTimeout}
75
73
  /** @type {Map<string, string>} */
76
74
  #clientTokens = new Map(); // ClientId -> Token
77
- /** @type {Map<string, number>} */
78
- #roomVersions = new Map(); // RoomId -> Version
79
75
 
80
76
  // Debug
81
77
  #debug = false;
@@ -172,12 +168,6 @@ export default class PlaySocketServer {
172
168
  return;
173
169
  }
174
170
 
175
- // Register client ID if provided & check for a duplicate
176
- if (data.id && (this.#clients.get(data.id) || this.#pendingDisconnects.has(data.id) || data.id === "server")) {
177
- ws.send(encode({ type: "registration_failed", reason: "ID is taken" }), { binary: true });
178
- return;
179
- }
180
-
181
171
  // Generate client ID if none provided
182
172
  if (!data.id) {
183
173
  for (let i = 0; i < 50; i++) {
@@ -203,6 +193,15 @@ export default class PlaySocketServer {
203
193
  return;
204
194
  }
205
195
 
196
+ // Silently abort if the socket closed or if that same socket has already registered (e.g. duplicate registration message)
197
+ if (ws.readyState !== ws.OPEN || ws.clientId) return;
198
+
199
+ // Abort if the ID is taken (or was taken during the async event callback)
200
+ if (this.#clients.has(data.id) || this.#pendingDisconnects.has(data.id)) {
201
+ ws.send(encode({ type: "registration_failed", reason: "ID is taken" }), { binary: true });
202
+ return;
203
+ }
204
+
206
205
  ws.clientId = data.id; // Adds the provided id to the ws object as clientId
207
206
  this.#clients.set(data.id, ws);
208
207
  const sessionToken = this.#generateSessionToken();
@@ -213,6 +212,8 @@ export default class PlaySocketServer {
213
212
  }
214
213
 
215
214
  case "reconnect": {
215
+ if (ws.clientId) return; // Clients must reconnect with a fresh, unregistered socket
216
+
216
217
  // If user is pending disconnect or still on a dropped connection, respond (otherwise it's too late)
217
218
  const pendingDisconnect = this.#pendingDisconnects.get(data.id);
218
219
  const staleSocket = this.#clients.get(data.id); // Client requests reconnect even though the server thinks they are still connected
@@ -228,9 +229,8 @@ export default class PlaySocketServer {
228
229
  clearTimeout(pendingDisconnect?.timeout);
229
230
  this.#pendingDisconnects.delete(data.id);
230
231
 
231
- // Only terminate if it's a different connection sending the reconnect
232
- if (staleSocket && staleSocket !== ws) {
233
- staleSocket.clientId = null; // So the close skips disconnection (handleDisconnection exits if the client ID is falsy)
232
+ if (staleSocket) {
233
+ staleSocket.clientId = null; // To skip handleDisconnection() on socket "close"
234
234
  staleSocket.terminate();
235
235
  }
236
236
 
@@ -243,18 +243,19 @@ export default class PlaySocketServer {
243
243
  const formerRoomId = this.#clientRooms.get(data.id);
244
244
  const formerRoom = this.#rooms[formerRoomId];
245
245
  if (formerRoom) {
246
- // If the room has no host (they were the only one and disconnected), restore them as host
247
- if (formerRoom.host === null) formerRoom.host = data.id;
248
246
  if (this.#debug) console.log(LOG_PREFIX + `State sent for reconnection for room ${formerRoomId}:`, formerRoom.crdtManager.state);
249
247
  roomData = {
248
+ roomId: formerRoomId,
250
249
  state: formerRoom.crdtManager.state,
251
250
  participantCount: formerRoom.participants.length,
252
- host: formerRoom.host,
253
- version: this.#roomVersions.get(formerRoomId)
251
+ host: formerRoom.host
254
252
  }
255
253
  }
256
254
 
257
255
  ws.send(encode({ type: "reconnected", roomData }), { binary: true });
256
+
257
+ // If the host has disconnected or is pending disconnect, migrate host (after reconnected so hostMigrated event fires correctly on client)
258
+ if (formerRoom && formerRoom.host !== null && !this.#clients.has(formerRoom.host)) this.#migrateHost(formerRoomId, formerRoom.host);
258
259
  } else {
259
260
  ws.send(encode({ type: "reconnection_failed", reason: "Client unknown to server" }), { binary: true });
260
261
  }
@@ -264,37 +265,39 @@ export default class PlaySocketServer {
264
265
  case "create_room": {
265
266
  if (!ws.clientId) return;
266
267
 
267
- if (this.#clientRooms.get(ws.clientId)) {
268
- ws.send(encode({
269
- type: "room_creation_failed",
270
- reason: "Already in a room"
271
- }), { binary: true });
272
- return;
273
- }
274
-
275
268
  // Event callback with potential initial storage modifications
276
269
  const reviewedStorage = await this.#triggerEvent("roomCreationRequested", { clientId: ws.clientId, initialStorage: structuredClone({ ...data.initialStorage }) });
277
270
  if (typeof reviewedStorage === "object") data.initialStorage = reviewedStorage;
271
+
272
+ // Verify client is still connected and abort if not
273
+ if (!this.#getClient(ws.clientId)) {
274
+ if (this.#debug) console.log(LOG_PREFIX + `Room creation cancelled, client ${ws.clientId} disconnected during event callback`);
275
+ return;
276
+ }
277
+
278
278
  if (reviewedStorage === false || typeof reviewedStorage === "string") {
279
- ws.send(encode({ type: "room_creation_failed", reason: typeof reviewedStorage === "string" ? reviewedStorage : null }), { binary: true });
279
+ this.#getClient(ws.clientId)?.send(encode({ type: "room_creation_failed", reason: typeof reviewedStorage === "string" ? reviewedStorage : null }), { binary: true });
280
280
  return;
281
281
  }
282
282
 
283
283
  try {
284
- // Verify client is still connected and abort if not
285
- if (!this.#clients.has(ws.clientId)) {
286
- if (this.#debug) console.log(LOG_PREFIX + `Room creation cancelled, client ${ws.clientId} disconnected during event callback`);
284
+ // Ensure client isn't already in another room (after the async event callback)
285
+ if (this.#clientRooms.get(ws.clientId)) {
286
+ this.#getClient(ws.clientId)?.send(encode({
287
+ type: "room_creation_failed",
288
+ reason: "Already in a room"
289
+ }), { binary: true });
287
290
  return;
288
291
  }
289
292
 
290
- const newRoom = this.#createRoom(ws.clientId, data.initialStorage, data.size); // Create room
293
+ const newRoom = this.#createRoom(ws.clientId, data.initialStorage); // Create room
291
294
 
292
295
  this.#rooms[newRoom.id].participants.push(ws.clientId) // Add client to the room
293
296
  this.#clientRooms.set(ws.clientId, newRoom.id); // Add client to the client-room map
294
- ws.send(encode({ type: "room_created", state: newRoom.state, roomId: newRoom.id, participantCount: this.#rooms[newRoom.id].participants.length }), { binary: true });
297
+ this.#getClient(ws.clientId)?.send(encode({ type: "room_created", state: newRoom.state, roomId: newRoom.id, participantCount: this.#rooms[newRoom.id].participants.length }), { binary: true });
295
298
  } catch (error) {
296
299
  console.error(ERROR_PREFIX + "Error creating room:", error);
297
- ws.send(encode({ type: "room_creation_failed", reason: error.message }), { binary: true });
300
+ this.#getClient(ws.clientId)?.send(encode({ type: "room_creation_failed", reason: error.message }), { binary: true });
298
301
  }
299
302
  break;
300
303
  }
@@ -305,15 +308,17 @@ export default class PlaySocketServer {
305
308
 
306
309
  /** @param {string} reason */
307
310
  const rejectJoin = (reason) => {
308
- ws.send(encode({ type: "join_rejected", reason }), { binary: true });
311
+ this.#getClient(ws.clientId)?.send(encode({ type: "join_rejected", reason }), { binary: true });
309
312
  };
310
313
 
314
+ if (!this.#rooms[roomId]) return rejectJoin("Invalid room ID");
315
+
311
316
  // Event callback
312
317
  const joinAllowed = await this.#triggerEvent("clientJoinRequested", ws.clientId, roomId);
313
318
  if (joinAllowed === false || typeof joinAllowed === "string") return rejectJoin(typeof joinAllowed === "string" ? joinAllowed : null);
314
319
 
315
320
  // Verify client is still connected and abort if not
316
- if (!this.#clients.has(ws.clientId)) {
321
+ if (!this.#getClient(ws.clientId)) {
317
322
  if (this.#debug) console.log(LOG_PREFIX + `Room join cancelled, client ${ws.clientId} disconnected during event callback`);
318
323
  return;
319
324
  }
@@ -329,14 +334,14 @@ export default class PlaySocketServer {
329
334
 
330
335
  case "update_property": {
331
336
  const roomId = this.#clientRooms.get(ws.clientId);
332
- const room = roomId ? this.#rooms[roomId] : null;
337
+ const room = this.#rooms[roomId];
333
338
 
334
339
  if (room && data.update) {
335
340
  // Check if update is allowed via event callback (provide clone to ensure update integrity)
336
341
  // Must be synchronous so that messages that follow in the same read from the socket (which can flush multiple at once) get processed reliably in order (await would schedule to a new microtask, so the order can fall apart)
337
342
  const updateAllowed = this.#triggerEventSync("storageUpdateRequested", { roomId, clientId: ws.clientId, update: structuredClone(data.update), storage: this.getRoomStorage(roomId) });
338
343
  if (updateAllowed === false || typeof updateAllowed === "string") {
339
- ws.send(encode({
344
+ this.#getClient(ws.clientId)?.send(encode({
340
345
  type: "property_update_rejected",
341
346
  reason: typeof updateAllowed === "string" ? updateAllowed : null,
342
347
  update: data.update
@@ -346,17 +351,12 @@ export default class PlaySocketServer {
346
351
 
347
352
  room.crdtManager.importPropertyUpdate(data.update); // Import update into server state
348
353
 
349
- // Increment version for this room
350
- const currentVersion = this.#roomVersions.get(roomId) + 1;
351
- this.#roomVersions.set(roomId, currentVersion);
352
-
353
- room.participants?.forEach(p => {
354
+ room.participants.forEach(p => {
354
355
  const client = this.#clients.get(p);
355
356
  if (client) {
356
357
  client.send(encode({
357
358
  type: "property_updated",
358
- update: data.update,
359
- version: currentVersion
359
+ update: data.update
360
360
  }), { binary: true });
361
361
  }
362
362
  });
@@ -371,8 +371,8 @@ export default class PlaySocketServer {
371
371
  if (!ws.clientId) return;
372
372
  const roomId = this.#clientRooms.get(ws.clientId) || null;
373
373
  const requestSuccess = await this.#triggerEvent("requestReceived", { roomId, clientId: ws.clientId, name: data.request.name, data: data.request.data });
374
- if (requestSuccess === false || typeof requestSuccess === "string") ws.send(encode({ type: "request_failed", request: data.request, reason: typeof requestSuccess === "string" ? requestSuccess : null }), { binary: true });
375
- else ws.send(encode({ type: "request_succeeded", request: data.request }), { binary: true });
374
+ if (requestSuccess === false || typeof requestSuccess === "string") this.#getClient(ws.clientId)?.send(encode({ type: "request_failed", request: data.request, reason: typeof requestSuccess === "string" ? requestSuccess : null }), { binary: true });
375
+ else this.#getClient(ws.clientId)?.send(encode({ type: "request_succeeded", request: data.request }), { binary: true });
376
376
  break;
377
377
  }
378
378
 
@@ -395,31 +395,6 @@ export default class PlaySocketServer {
395
395
  return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
396
396
  }
397
397
 
398
- /**
399
- * Migrate the host role to another participant
400
- * @param {string} roomId - ID of the room
401
- * @param {string} clientId - ID of the current host
402
- */
403
- #migrateHost(roomId, clientId) {
404
- const room = this.#rooms[roomId];
405
- if (!room) throw new Error("Room not found");
406
- if (room.host !== clientId) throw new Error("Client is not the host");
407
-
408
- const remainingParticipants = room.participants.filter(p => p !== clientId);
409
-
410
- if (remainingParticipants.length >= 1) {
411
- // Inform all participants about the new host
412
- room.host = remainingParticipants[0];
413
- remainingParticipants.forEach(p => {
414
- const client = this.#clients.get(p);
415
- if (client) client.send(encode({
416
- type: "host_migrated",
417
- newHost: room.host,
418
- }), { binary: true });
419
- });
420
- } else room.host = null; // The next person to join will become the host
421
- }
422
-
423
398
  /**
424
399
  * Generate a random token to prevent malicious reconnect attempts
425
400
  * @returns {string} Token
@@ -428,6 +403,15 @@ export default class PlaySocketServer {
428
403
  return crypto.randomUUID();
429
404
  }
430
405
 
406
+ /**
407
+ * Get the latest socket (It can change through reconnect, relevant when event callbacks are awaited in handlers)
408
+ * @param {string} clientId - Client ID
409
+ * @returns {WebSocket | undefined} Socket
410
+ */
411
+ #getClient(clientId) {
412
+ return this.#clients.get(clientId);
413
+ }
414
+
431
415
  /**
432
416
  * Check rate limit using token bucket algorithm
433
417
  * @param {string} connUuid - Connection UUID (which is different from the client ID)
@@ -457,6 +441,33 @@ export default class PlaySocketServer {
457
441
  return true;
458
442
  }
459
443
 
444
+ /**
445
+ * Migrate the host role to another participant
446
+ * @param {string} roomId - ID of the room
447
+ * @param {string} clientId - ID of the current host
448
+ */
449
+ #migrateHost(roomId, clientId) {
450
+ const room = this.#rooms[roomId];
451
+ if (!room) throw new Error("Room not found");
452
+ if (room.host !== clientId) throw new Error("Client is not the host");
453
+
454
+ const remainingParticipants = room.participants.filter(p => p !== clientId);
455
+
456
+ if (remainingParticipants.length >= 1) {
457
+ // Change host (prefer connected participants over ones pending disconnect)
458
+ room.host = remainingParticipants.find(p => this.#clients.has(p)) ?? remainingParticipants[0];
459
+
460
+ // Inform all participants about the new host
461
+ remainingParticipants.forEach(p => {
462
+ const client = this.#clients.get(p);
463
+ if (client) client.send(encode({
464
+ type: "host_migrated",
465
+ newHost: room.host,
466
+ }), { binary: true });
467
+ });
468
+ }
469
+ }
470
+
460
471
  /**
461
472
  * Handle client disconnection
462
473
  * @param {WebSocket} ws - WebSocket client
@@ -469,30 +480,27 @@ export default class PlaySocketServer {
469
480
 
470
481
  // If client was in a room, check if they were the host & migrate
471
482
  const roomId = this.#clientRooms.get(ws.clientId);
472
- if (roomId != null && this.#rooms[roomId]?.host === ws.clientId) this.#migrateHost(roomId, ws.clientId);
483
+ if (roomId && this.#rooms[roomId].host === ws.clientId) this.#migrateHost(roomId, ws.clientId);
473
484
 
474
- if (ws.willfulDisconnect) this.#disconnectClient(ws); // Immediate disconnection
485
+ if (ws.willfulDisconnect) this.#disconnectClient(ws.clientId); // Immediate disconnection
475
486
  else {
476
487
  // Pending complete disconnection with 5s grace period to allow for reconnections
477
- this.#pendingDisconnects.set(ws.clientId, {
478
- timeout: setTimeout(() => {
479
- this.#disconnectClient(ws);
480
- }, RECONNECT_GRACE_PERIOD)
481
- });
488
+ const onTimeout = () => this.#disconnectClient(ws.clientId);
489
+ this.#pendingDisconnects.set(ws.clientId, { timeout: setTimeout(onTimeout, RECONNECT_GRACE_PERIOD), onTimeout });
482
490
  }
483
491
  }
484
492
  }
485
493
 
486
494
  /**
487
495
  * Disconnect a client
488
- * @param {WebSocket} ws - WebSocket client
496
+ * @param {string} clientId - Client ID
489
497
  */
490
- #disconnectClient(ws) {
491
- this.#pendingDisconnects.delete(ws.clientId);
492
- this.#clientTokens.delete(ws.clientId);
493
- const roomId = this.#clientRooms.get(ws.clientId);
494
- if (roomId && this.#rooms[roomId]) this.#leaveRoom(ws.clientId, roomId);
495
- this.#triggerEvent("clientDisconnected", ws.clientId);
498
+ #disconnectClient(clientId) {
499
+ this.#pendingDisconnects.delete(clientId);
500
+ this.#clientTokens.delete(clientId);
501
+ const roomId = this.#clientRooms.get(clientId);
502
+ if (roomId) this.#leaveRoom(clientId, roomId);
503
+ this.#triggerEvent("clientDisconnected", clientId);
496
504
  }
497
505
 
498
506
  /**
@@ -575,13 +583,23 @@ export default class PlaySocketServer {
575
583
  * Kick a client from the server
576
584
  * @param {string} clientId - Client ID
577
585
  * @param {string} [reason] - Optional reason
586
+ * @param {boolean} [shutdown=false] - Skip the disconnection and events
578
587
  */
579
- kick(clientId, reason = null) {
588
+ kick(clientId, reason = null, shutdown = false) {
580
589
  const client = this.#clients.get(clientId);
581
- if (!client) return; // Client not connected, skip
582
- client.willfulDisconnect = true;
583
- client.send(encode({ type: "kicked", reason }), { binary: true });
584
- client.close();
590
+
591
+ if (client) {
592
+ client.send(encode({ type: "kicked", reason }), { binary: true });
593
+ client.willfulDisconnect = true;
594
+ if (!shutdown) this.#handleDisconnection(client);
595
+ client.clientId = null; // Prevent handleDisconnection() from running on socket "close"
596
+ client.close();
597
+ } else if (this.#pendingDisconnects.has(clientId)) {
598
+ // Client is pending disconnect, fast-forward the grace period and fully disconnect
599
+ const { timeout, onTimeout } = this.#pendingDisconnects.get(clientId);
600
+ clearTimeout(timeout);
601
+ if (!shutdown) onTimeout();
602
+ }
585
603
  }
586
604
 
587
605
  /**
@@ -592,15 +610,12 @@ export default class PlaySocketServer {
592
610
  move(clientId, roomId) {
593
611
  const client = this.#clients.get(clientId);
594
612
  const oldRoomId = this.#clientRooms.get(clientId);
595
- const oldRoom = this.#rooms[oldRoomId];
596
613
  const targetRoom = this.#rooms[roomId];
597
614
  if (!client && !this.#pendingDisconnects.has(clientId)) throw new Error("Client not found");
598
615
  if (!oldRoomId) throw new Error("Client is not in a room");
599
- if (!oldRoom) throw new Error("Client room not found");
600
616
  if (oldRoomId === roomId) throw new Error("Client is already in target room");
601
617
  if (!targetRoom) throw new Error("Target room not found");
602
- if (targetRoom.participants.length >= targetRoom.size) throw new Error("Target room is full");
603
- if (oldRoom.host === clientId) this.#migrateHost(oldRoomId, clientId);
618
+ if (this.#rooms[oldRoomId].host === clientId) this.#migrateHost(oldRoomId, clientId);
604
619
  this.#leaveRoom(clientId, oldRoomId);
605
620
  this.#joinRoom(clientId, roomId);
606
621
  }
@@ -639,16 +654,13 @@ export default class PlaySocketServer {
639
654
  const room = this.#rooms[roomId];
640
655
  if (room) {
641
656
  const propertyUpdate = room.crdtManager.updateProperty(key, type, value, secondValue);
642
- const currentVersion = this.#roomVersions.get(roomId) + 1;
643
- this.#roomVersions.set(roomId, currentVersion); // Increment version for this room
644
657
 
645
- room.participants?.forEach(p => {
658
+ room.participants.forEach(p => {
646
659
  const client = this.#clients.get(p);
647
660
  if (client) {
648
661
  client.send(encode({
649
662
  type: "property_updated",
650
- update: propertyUpdate,
651
- version: currentVersion
663
+ update: propertyUpdate
652
664
  }), { binary: true });
653
665
  }
654
666
  });
@@ -659,21 +671,19 @@ export default class PlaySocketServer {
659
671
  /**
660
672
  * Create a server-owned room
661
673
  * @param {object | null} [initialStorage] - Optional initial storage object
662
- * @param {number} [size] - Max. room size, up to 500
663
674
  * @returns {{ state: CRDTState, id: string }} Object containing room state and room ID
664
675
  */
665
- createRoom(initialStorage, size) {
666
- return this.#createRoom("server", initialStorage, size);
676
+ createRoom(initialStorage) {
677
+ return this.#createRoom(null, initialStorage);
667
678
  }
668
679
 
669
680
  /**
670
681
  * Create a room
671
- * @param {string} host - Host ID (when set to "server", room will not be deleted if all clients leave)
682
+ * @param {string | null} host - Host ID (when set to null, the room was created by the server & will not be deleted if all clients leave)
672
683
  * @param {object | null} [initialStorage] - Optional initial storage object
673
- * @param {number} [size] - Max. room size, up to 500
674
684
  * @returns {{ state: CRDTState, id: string }} Object containing room state and room ID
675
685
  */
676
- #createRoom(host, initialStorage, size) {
686
+ #createRoom(host, initialStorage) {
677
687
  let newRoomId;
678
688
 
679
689
  for (let i = 0; i < 100; i++) {
@@ -691,11 +701,9 @@ export default class PlaySocketServer {
691
701
  roomCrdtManager.updateProperty(key, "set", value);
692
702
  });
693
703
 
694
- this.#roomVersions.set(newRoomId, 0);
695
704
  this.#rooms[newRoomId] = {
696
705
  participants: [],
697
706
  host,
698
- size: Math.min(Number(size), MAX_ROOM_SIZE) || MAX_ROOM_SIZE,
699
707
  crdtManager: roomCrdtManager
700
708
  };
701
709
 
@@ -715,7 +723,6 @@ export default class PlaySocketServer {
715
723
  if (!room) throw new Error("Room not found");
716
724
  if (!client && !this.#pendingDisconnects.has(clientId)) throw new Error("Client not found");
717
725
  if (this.#clientRooms.get(clientId)) throw new Error("Already in a room");
718
- if (room.participants.length >= room.size) throw new Error("Room full");
719
726
 
720
727
  room.participants.push(clientId);
721
728
  this.#clientRooms.set(clientId, roomId);
@@ -728,23 +735,12 @@ export default class PlaySocketServer {
728
735
  roomId,
729
736
  state: room.crdtManager.state,
730
737
  participantCount: room.participants.length,
731
- host: room.host,
732
- version: this.#roomVersions.get(roomId)
738
+ host: room.host
733
739
  }), { binary: true });
734
740
  }
735
741
 
736
- // If the room has no host (previous host disconnected and was the only participant), make joiner the host
737
- // Set new host after join_accepted so hostMigrated event fires correctly on client
738
- const becameHost = room.host === null;
739
- if (becameHost) {
740
- room.host = clientId; // Change host
741
- if (client) {
742
- client.send(encode({
743
- type: "host_migrated",
744
- newHost: room.host,
745
- }), { binary: true });
746
- }
747
- }
742
+ // If the host has disconnected or is pending disconnect, migrate host (after join_accepted so hostMigrated event fires correctly on client)
743
+ if (room.host !== null && !this.#clients.has(room.host)) this.#migrateHost(roomId, room.host);
748
744
 
749
745
  // Notify existing participants
750
746
  room.participants.forEach(p => {
@@ -774,8 +770,8 @@ export default class PlaySocketServer {
774
770
  this.#clientRooms.delete(clientId);
775
771
  this.#triggerEvent("clientLeftRoom", clientId, roomId); // Before potential room destruction to allow for accessing its data in callback
776
772
 
777
- if (room.participants.length === 0 && room.host !== "server") {
778
- this.destroyRoom(roomId); // Destroy room if now empty & not by server
773
+ if (room.participants.length === 0 && room.host !== null) {
774
+ this.#deleteRoom(roomId); // Delete empty room that isn't server-created
779
775
  } else {
780
776
  // Notify remaining participants
781
777
  room.participants.forEach(p => {
@@ -797,15 +793,22 @@ export default class PlaySocketServer {
797
793
  const room = this.#rooms[roomId];
798
794
  if (!room) throw new Error("Room not found");
799
795
 
800
- // Disconnect clients if still in room
801
- const participants = room.participants;
802
- participants.forEach((clientId) => {
803
- this.kick(clientId, "Room destroyed by server");
804
- });
796
+ room.participants.forEach((clientId) => this.kick(clientId, "Room destroyed by server"));
797
+
798
+ // Server-created rooms aren't auto-deleted when all clients leave
799
+ if (room.host === null) this.#deleteRoom(roomId);
800
+ }
801
+
802
+ /**
803
+ * Delete a room
804
+ * @param {string} roomId - Room ID
805
+ */
806
+ #deleteRoom(roomId) {
807
+ const room = this.#rooms[roomId];
808
+ if (!room) throw new Error("Room not found");
809
+ if (room.participants.length > 0) throw new Error("Cannot delete non-empty room");
805
810
 
806
- // Delete the room
807
811
  delete this.#rooms[roomId];
808
- this.#roomVersions.delete(roomId); // Delete room version (used to ensure all clients are up-2-date)
809
812
  this.#triggerEvent("roomDestroyed", roomId);
810
813
  if (this.#debug) console.log(LOG_PREFIX + "Deleted room with id " + roomId);
811
814
  }
@@ -815,14 +818,9 @@ export default class PlaySocketServer {
815
818
  */
816
819
  stop() {
817
820
  clearInterval(this.#heartbeatInterval);
818
- if (this.#wss) {
819
- this.#clients.forEach((client, clientId) => this.kick(clientId, "Server restart"));
820
- this.#wss.close();
821
- }
821
+ [...this.#clients.keys(), ...this.#pendingDisconnects.keys()].forEach((clientId) => this.kick(clientId, "Server restart", true));
822
+ this.#wss.close();
822
823
  if (this.#server && this.#ownsServer) this.#server.close(() => { console.log(LOG_PREFIX + "Server stopped"); });
823
- this.#pendingDisconnects.forEach((data) => {
824
- clearTimeout(data.timeout);
825
- });
826
824
  }
827
825
 
828
826
  get rooms() { return structuredClone(this.#rooms) }
@@ -6,7 +6,7 @@ declare module "ws" {
6
6
  interface WebSocket {
7
7
  uuid: string;
8
8
  isAlive: boolean;
9
- clientId?: string;
9
+ clientId?: string | null;
10
10
  isTerminating?: boolean;
11
11
  willfulDisconnect?: boolean;
12
12
  }
@@ -9,7 +9,7 @@ export const HEARTBEAT_INTERVAL = 5000;
9
9
  /**
10
10
  * Library version, must match package.json
11
11
  */
12
- export const VERSION = "5.3.2";
12
+ export const VERSION = "6.0.0";
13
13
 
14
14
  /**
15
15
  * Logging prefixes
@@ -82,10 +82,9 @@ export default class PlaySocket {
82
82
  /**
83
83
  * Create a new room and become host
84
84
  * @param {object | null} [initialStorage] - Initial state
85
- * @param {number} [size] - Max number of participants
86
85
  * @returns {Promise<string>} Resolves with room ID
87
86
  */
88
- createRoom(initialStorage?: object | null, size?: number): Promise<string>;
87
+ createRoom(initialStorage?: object | null): Promise<string>;
89
88
  /**
90
89
  * Join an existing room
91
90
  * @param {string} roomId - ID of the room
@@ -21,13 +21,9 @@ export type Room = {
21
21
  */
22
22
  participants: string[];
23
23
  /**
24
- * - Client ID of the room host ("server" for server-owned rooms, null if the room currently has no host)
24
+ * - Client ID of the room host (null for server-created rooms)
25
25
  */
26
26
  host: string | null;
27
- /**
28
- * - Max. number of participants
29
- */
30
- size: number;
31
27
  /**
32
28
  * - CRDT manager holding the room storage
33
29
  */
@@ -80,8 +76,7 @@ export type ServerEventMap = {
80
76
  /**
81
77
  * @typedef {object} Room
82
78
  * @property {string[]} participants - Client IDs of the room participants
83
- * @property {string | null} host - Client ID of the room host ("server" for server-owned rooms, null if the room currently has no host)
84
- * @property {number} size - Max. number of participants
79
+ * @property {string | null} host - Client ID of the room host (null for server-created rooms)
85
80
  * @property {CRDTManager} crdtManager - CRDT manager holding the room storage
86
81
  */
87
82
  /** @typedef {boolean | string | void} RejectViaStringOrBool */
@@ -138,8 +133,9 @@ export default class PlaySocketServer {
138
133
  * Kick a client from the server
139
134
  * @param {string} clientId - Client ID
140
135
  * @param {string} [reason] - Optional reason
136
+ * @param {boolean} [shutdown=false] - Skip the disconnection and events
141
137
  */
142
- kick(clientId: string, reason?: string): void;
138
+ kick(clientId: string, reason?: string, shutdown?: boolean): void;
143
139
  /**
144
140
  * Move client into a different room
145
141
  * @param {string} clientId - Client ID
@@ -179,10 +175,9 @@ export default class PlaySocketServer {
179
175
  /**
180
176
  * Create a server-owned room
181
177
  * @param {object | null} [initialStorage] - Optional initial storage object
182
- * @param {number} [size] - Max. room size, up to 500
183
178
  * @returns {{ state: CRDTState, id: string }} Object containing room state and room ID
184
179
  */
185
- createRoom(initialStorage?: object | null, size?: number): {
180
+ createRoom(initialStorage?: object | null): {
186
181
  state: CRDTState;
187
182
  id: string;
188
183
  };
@@ -8,7 +8,7 @@ export declare const HEARTBEAT_INTERVAL = 5000;
8
8
  /**
9
9
  * Library version, must match package.json
10
10
  */
11
- export declare const VERSION = "5.3.2";
11
+ export declare const VERSION = "6.0.0";
12
12
  /**
13
13
  * Logging prefixes
14
14
  */