playsocketjs 5.3.1 → 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.1",
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,13 +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
- const updateAllowed = await this.#triggerEvent("storageUpdateRequested", { roomId, clientId: ws.clientId, update: structuredClone(data.update), storage: this.getRoomStorage(roomId) });
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)
342
+ const updateAllowed = this.#triggerEventSync("storageUpdateRequested", { roomId, clientId: ws.clientId, update: structuredClone(data.update), storage: this.getRoomStorage(roomId) });
337
343
  if (updateAllowed === false || typeof updateAllowed === "string") {
338
- ws.send(encode({
344
+ this.#getClient(ws.clientId)?.send(encode({
339
345
  type: "property_update_rejected",
340
346
  reason: typeof updateAllowed === "string" ? updateAllowed : null,
341
347
  update: data.update
@@ -345,17 +351,12 @@ export default class PlaySocketServer {
345
351
 
346
352
  room.crdtManager.importPropertyUpdate(data.update); // Import update into server state
347
353
 
348
- // Increment version for this room
349
- const currentVersion = this.#roomVersions.get(roomId) + 1;
350
- this.#roomVersions.set(roomId, currentVersion);
351
-
352
- room.participants?.forEach(p => {
354
+ room.participants.forEach(p => {
353
355
  const client = this.#clients.get(p);
354
356
  if (client) {
355
357
  client.send(encode({
356
358
  type: "property_updated",
357
- update: data.update,
358
- version: currentVersion
359
+ update: data.update
359
360
  }), { binary: true });
360
361
  }
361
362
  });
@@ -370,8 +371,8 @@ export default class PlaySocketServer {
370
371
  if (!ws.clientId) return;
371
372
  const roomId = this.#clientRooms.get(ws.clientId) || null;
372
373
  const requestSuccess = await this.#triggerEvent("requestReceived", { roomId, clientId: ws.clientId, name: data.request.name, data: data.request.data });
373
- if (requestSuccess === false || typeof requestSuccess === "string") ws.send(encode({ type: "request_failed", request: data.request, reason: typeof requestSuccess === "string" ? requestSuccess : null }), { binary: true });
374
- 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 });
375
376
  break;
376
377
  }
377
378
 
@@ -394,31 +395,6 @@ export default class PlaySocketServer {
394
395
  return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
395
396
  }
396
397
 
397
- /**
398
- * Migrate the host role to another participant
399
- * @param {string} roomId - ID of the room
400
- * @param {string} clientId - ID of the current host
401
- */
402
- #migrateHost(roomId, clientId) {
403
- const room = this.#rooms[roomId];
404
- if (!room) throw new Error("Room not found");
405
- if (room.host !== clientId) throw new Error("Client is not the host");
406
-
407
- const remainingParticipants = room.participants.filter(p => p !== clientId);
408
-
409
- if (remainingParticipants.length >= 1) {
410
- // Inform all participants about the new host
411
- room.host = remainingParticipants[0];
412
- remainingParticipants.forEach(p => {
413
- const client = this.#clients.get(p);
414
- if (client) client.send(encode({
415
- type: "host_migrated",
416
- newHost: room.host,
417
- }), { binary: true });
418
- });
419
- } else room.host = null; // The next person to join will become the host
420
- }
421
-
422
398
  /**
423
399
  * Generate a random token to prevent malicious reconnect attempts
424
400
  * @returns {string} Token
@@ -427,6 +403,15 @@ export default class PlaySocketServer {
427
403
  return crypto.randomUUID();
428
404
  }
429
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
+
430
415
  /**
431
416
  * Check rate limit using token bucket algorithm
432
417
  * @param {string} connUuid - Connection UUID (which is different from the client ID)
@@ -456,6 +441,33 @@ export default class PlaySocketServer {
456
441
  return true;
457
442
  }
458
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
+
459
471
  /**
460
472
  * Handle client disconnection
461
473
  * @param {WebSocket} ws - WebSocket client
@@ -468,30 +480,27 @@ export default class PlaySocketServer {
468
480
 
469
481
  // If client was in a room, check if they were the host & migrate
470
482
  const roomId = this.#clientRooms.get(ws.clientId);
471
- 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);
472
484
 
473
- if (ws.willfulDisconnect) this.#disconnectClient(ws); // Immediate disconnection
485
+ if (ws.willfulDisconnect) this.#disconnectClient(ws.clientId); // Immediate disconnection
474
486
  else {
475
487
  // Pending complete disconnection with 5s grace period to allow for reconnections
476
- this.#pendingDisconnects.set(ws.clientId, {
477
- timeout: setTimeout(() => {
478
- this.#disconnectClient(ws);
479
- }, RECONNECT_GRACE_PERIOD)
480
- });
488
+ const onTimeout = () => this.#disconnectClient(ws.clientId);
489
+ this.#pendingDisconnects.set(ws.clientId, { timeout: setTimeout(onTimeout, RECONNECT_GRACE_PERIOD), onTimeout });
481
490
  }
482
491
  }
483
492
  }
484
493
 
485
494
  /**
486
495
  * Disconnect a client
487
- * @param {WebSocket} ws - WebSocket client
496
+ * @param {string} clientId - Client ID
488
497
  */
489
- #disconnectClient(ws) {
490
- this.#pendingDisconnects.delete(ws.clientId);
491
- this.#clientTokens.delete(ws.clientId);
492
- const roomId = this.#clientRooms.get(ws.clientId);
493
- if (roomId && this.#rooms[roomId]) this.#leaveRoom(ws.clientId, roomId);
494
- 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);
495
504
  }
496
505
 
497
506
  /**
@@ -525,20 +534,41 @@ export default class PlaySocketServer {
525
534
  * @returns {Promise<any>} The first non-null callback return value if present (all callbacks run, a throwing callback counts as returning false), otherwise true
526
535
  */
527
536
  async #triggerEvent(event, ...args) {
528
- const syncOnlyEvents = ["storageUpdateRequested"]; // Async storage validation could mess up GC and would lead to potentially poor UX (slow sync)
529
537
  const callbacks = this.#callbacks.get(event);
530
- if (!callbacks) return;
538
+ if (!callbacks) return true;
539
+
540
+ let firstResult;
541
+ for (const callback of [...callbacks]) {
542
+ try {
543
+ let result = callback(...args);
544
+ if (typeof result?.then === "function") result = await result;
545
+ if (firstResult == null && result != null) firstResult = result;
546
+ } catch (error) {
547
+ console.error(ERROR_PREFIX + `${event} callback error:`, error);
548
+ if (firstResult == null) firstResult = false; // Fail closed (false blocks for most events)
549
+ }
550
+ }
551
+ return firstResult ?? true;
552
+ }
553
+
554
+ /**
555
+ * Trigger an event to registered synchronous callbacks (e.g. storage validation, async validation could mess up GC and lead to poor UX)
556
+ * @param {string} event - Event name
557
+ * @param {...*} args - Arguments
558
+ * @returns {any} The first non-null callback return value if present (all callbacks run, a throwing callback counts as returning false, an async one as a rejection), otherwise true
559
+ */
560
+ #triggerEventSync(event, ...args) {
561
+ const callbacks = this.#callbacks.get(event);
562
+ if (!callbacks) return true;
531
563
 
532
564
  let firstResult;
533
565
  for (const callback of [...callbacks]) {
534
566
  try {
535
567
  let result = callback(...args);
536
568
  if (typeof result?.then === "function") {
537
- if (syncOnlyEvents.includes(event)) {
538
- result.catch(() => { });
539
- console.error(ERROR_PREFIX + `${event} callbacks must be synchronous`);
540
- result = `${event} callback must be synchronous.`; // Rejection string to explicitly block this practice
541
- } else result = await result;
569
+ result.catch(() => { });
570
+ console.error(ERROR_PREFIX + `${event} callbacks must be synchronous`);
571
+ result = `${event} callback must be synchronous`; // Rejection string to explicitly block this practice
542
572
  }
543
573
  if (firstResult == null && result != null) firstResult = result;
544
574
  } catch (error) {
@@ -553,13 +583,23 @@ export default class PlaySocketServer {
553
583
  * Kick a client from the server
554
584
  * @param {string} clientId - Client ID
555
585
  * @param {string} [reason] - Optional reason
586
+ * @param {boolean} [shutdown=false] - Skip the disconnection and events
556
587
  */
557
- kick(clientId, reason = null) {
588
+ kick(clientId, reason = null, shutdown = false) {
558
589
  const client = this.#clients.get(clientId);
559
- if (!client) return; // Client not connected, skip
560
- client.willfulDisconnect = true;
561
- client.send(encode({ type: "kicked", reason }), { binary: true });
562
- 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
+ }
563
603
  }
564
604
 
565
605
  /**
@@ -570,15 +610,12 @@ export default class PlaySocketServer {
570
610
  move(clientId, roomId) {
571
611
  const client = this.#clients.get(clientId);
572
612
  const oldRoomId = this.#clientRooms.get(clientId);
573
- const oldRoom = this.#rooms[oldRoomId];
574
613
  const targetRoom = this.#rooms[roomId];
575
614
  if (!client && !this.#pendingDisconnects.has(clientId)) throw new Error("Client not found");
576
615
  if (!oldRoomId) throw new Error("Client is not in a room");
577
- if (!oldRoom) throw new Error("Client room not found");
578
616
  if (oldRoomId === roomId) throw new Error("Client is already in target room");
579
617
  if (!targetRoom) throw new Error("Target room not found");
580
- if (targetRoom.participants.length >= targetRoom.size) throw new Error("Target room is full");
581
- if (oldRoom.host === clientId) this.#migrateHost(oldRoomId, clientId);
618
+ if (this.#rooms[oldRoomId].host === clientId) this.#migrateHost(oldRoomId, clientId);
582
619
  this.#leaveRoom(clientId, oldRoomId);
583
620
  this.#joinRoom(clientId, roomId);
584
621
  }
@@ -617,16 +654,13 @@ export default class PlaySocketServer {
617
654
  const room = this.#rooms[roomId];
618
655
  if (room) {
619
656
  const propertyUpdate = room.crdtManager.updateProperty(key, type, value, secondValue);
620
- const currentVersion = this.#roomVersions.get(roomId) + 1;
621
- this.#roomVersions.set(roomId, currentVersion); // Increment version for this room
622
657
 
623
- room.participants?.forEach(p => {
658
+ room.participants.forEach(p => {
624
659
  const client = this.#clients.get(p);
625
660
  if (client) {
626
661
  client.send(encode({
627
662
  type: "property_updated",
628
- update: propertyUpdate,
629
- version: currentVersion
663
+ update: propertyUpdate
630
664
  }), { binary: true });
631
665
  }
632
666
  });
@@ -637,21 +671,19 @@ export default class PlaySocketServer {
637
671
  /**
638
672
  * Create a server-owned room
639
673
  * @param {object | null} [initialStorage] - Optional initial storage object
640
- * @param {number} [size] - Max. room size, up to 500
641
674
  * @returns {{ state: CRDTState, id: string }} Object containing room state and room ID
642
675
  */
643
- createRoom(initialStorage, size) {
644
- return this.#createRoom("server", initialStorage, size);
676
+ createRoom(initialStorage) {
677
+ return this.#createRoom(null, initialStorage);
645
678
  }
646
679
 
647
680
  /**
648
681
  * Create a room
649
- * @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)
650
683
  * @param {object | null} [initialStorage] - Optional initial storage object
651
- * @param {number} [size] - Max. room size, up to 500
652
684
  * @returns {{ state: CRDTState, id: string }} Object containing room state and room ID
653
685
  */
654
- #createRoom(host, initialStorage, size) {
686
+ #createRoom(host, initialStorage) {
655
687
  let newRoomId;
656
688
 
657
689
  for (let i = 0; i < 100; i++) {
@@ -669,11 +701,9 @@ export default class PlaySocketServer {
669
701
  roomCrdtManager.updateProperty(key, "set", value);
670
702
  });
671
703
 
672
- this.#roomVersions.set(newRoomId, 0);
673
704
  this.#rooms[newRoomId] = {
674
705
  participants: [],
675
706
  host,
676
- size: Math.min(Number(size), MAX_ROOM_SIZE) || MAX_ROOM_SIZE,
677
707
  crdtManager: roomCrdtManager
678
708
  };
679
709
 
@@ -693,7 +723,6 @@ export default class PlaySocketServer {
693
723
  if (!room) throw new Error("Room not found");
694
724
  if (!client && !this.#pendingDisconnects.has(clientId)) throw new Error("Client not found");
695
725
  if (this.#clientRooms.get(clientId)) throw new Error("Already in a room");
696
- if (room.participants.length >= room.size) throw new Error("Room full");
697
726
 
698
727
  room.participants.push(clientId);
699
728
  this.#clientRooms.set(clientId, roomId);
@@ -706,23 +735,12 @@ export default class PlaySocketServer {
706
735
  roomId,
707
736
  state: room.crdtManager.state,
708
737
  participantCount: room.participants.length,
709
- host: room.host,
710
- version: this.#roomVersions.get(roomId)
738
+ host: room.host
711
739
  }), { binary: true });
712
740
  }
713
741
 
714
- // If the room has no host (previous host disconnected and was the only participant), make joiner the host
715
- // Set new host after join_accepted so hostMigrated event fires correctly on client
716
- const becameHost = room.host === null;
717
- if (becameHost) {
718
- room.host = clientId; // Change host
719
- if (client) {
720
- client.send(encode({
721
- type: "host_migrated",
722
- newHost: room.host,
723
- }), { binary: true });
724
- }
725
- }
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);
726
744
 
727
745
  // Notify existing participants
728
746
  room.participants.forEach(p => {
@@ -752,8 +770,8 @@ export default class PlaySocketServer {
752
770
  this.#clientRooms.delete(clientId);
753
771
  this.#triggerEvent("clientLeftRoom", clientId, roomId); // Before potential room destruction to allow for accessing its data in callback
754
772
 
755
- if (room.participants.length === 0 && room.host !== "server") {
756
- 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
757
775
  } else {
758
776
  // Notify remaining participants
759
777
  room.participants.forEach(p => {
@@ -775,15 +793,22 @@ export default class PlaySocketServer {
775
793
  const room = this.#rooms[roomId];
776
794
  if (!room) throw new Error("Room not found");
777
795
 
778
- // Disconnect clients if still in room
779
- const participants = room.participants;
780
- participants.forEach((clientId) => {
781
- this.kick(clientId, "Room destroyed by server");
782
- });
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");
783
810
 
784
- // Delete the room
785
811
  delete this.#rooms[roomId];
786
- this.#roomVersions.delete(roomId); // Delete room version (used to ensure all clients are up-2-date)
787
812
  this.#triggerEvent("roomDestroyed", roomId);
788
813
  if (this.#debug) console.log(LOG_PREFIX + "Deleted room with id " + roomId);
789
814
  }
@@ -793,14 +818,9 @@ export default class PlaySocketServer {
793
818
  */
794
819
  stop() {
795
820
  clearInterval(this.#heartbeatInterval);
796
- if (this.#wss) {
797
- this.#clients.forEach((client, clientId) => this.kick(clientId, "Server restart"));
798
- this.#wss.close();
799
- }
821
+ [...this.#clients.keys(), ...this.#pendingDisconnects.keys()].forEach((clientId) => this.kick(clientId, "Server restart", true));
822
+ this.#wss.close();
800
823
  if (this.#server && this.#ownsServer) this.#server.close(() => { console.log(LOG_PREFIX + "Server stopped"); });
801
- this.#pendingDisconnects.forEach((data) => {
802
- clearTimeout(data.timeout);
803
- });
804
824
  }
805
825
 
806
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.1";
12
+ export const VERSION = "6.0.0";
13
13
 
14
14
  /**
15
15
  * Logging prefixes
@@ -451,8 +451,8 @@ export default class CRDTManager {
451
451
  try {
452
452
  return structuredClone(this.#propertyStore); // Return deep clone
453
453
  } catch {
454
- console.error(CONSOLE_PREFIX + "Failed to parse property store")
455
- return {}
454
+ console.error(CONSOLE_PREFIX + "Failed to parse property store");
455
+ return {};
456
456
  }
457
457
  }
458
458
 
@@ -475,7 +475,7 @@ export default class CRDTManager {
475
475
  return true;
476
476
  }
477
477
  } catch {
478
- console.error(CONSOLE_PREFIX + "Failed to stringify and compare property stores")
478
+ console.error(CONSOLE_PREFIX + "Failed to stringify and compare property stores");
479
479
  }
480
480
  return false;
481
481
  }
@@ -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.1";
11
+ export declare const VERSION = "6.0.0";
12
12
  /**
13
13
  * Logging prefixes
14
14
  */