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 +20 -6
- package/package.json +1 -1
- package/src/client/client.js +15 -22
- package/src/server/server.js +168 -148
- package/src/server/ws-extensions.d.ts +1 -1
- package/src/universal/constants.js +1 -1
- package/src/universal/crdtManager.js +3 -3
- package/types/client/client.d.ts +1 -2
- package/types/server/server.d.ts +5 -10
- package/types/universal/constants.d.ts +1 -1
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
|
|
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
|
|
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
|
|
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
package/src/client/client.js
CHANGED
|
@@ -52,11 +52,10 @@ export default class PlaySocket {
|
|
|
52
52
|
// Room properties
|
|
53
53
|
/** @type {string | null | undefined} */
|
|
54
54
|
#roomHost;
|
|
55
|
-
/** @type {
|
|
56
|
-
#
|
|
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.#
|
|
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
|
-
|
|
261
|
-
// If
|
|
262
|
-
|
|
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.#
|
|
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
|
|
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.#
|
|
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.#
|
|
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"));
|
package/src/server/server.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
//
|
|
285
|
-
if (
|
|
286
|
-
|
|
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
|
|
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.#
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
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
|
|
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.#
|
|
477
|
-
|
|
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 {
|
|
496
|
+
* @param {string} clientId - Client ID
|
|
488
497
|
*/
|
|
489
|
-
#disconnectClient(
|
|
490
|
-
this.#pendingDisconnects.delete(
|
|
491
|
-
this.#clientTokens.delete(
|
|
492
|
-
const roomId = this.#clientRooms.get(
|
|
493
|
-
if (roomId
|
|
494
|
-
this.#triggerEvent("clientDisconnected",
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
560
|
-
client
|
|
561
|
-
|
|
562
|
-
|
|
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 (
|
|
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
|
|
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
|
|
644
|
-
return this.#createRoom(
|
|
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
|
|
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
|
|
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
|
|
715
|
-
|
|
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 !==
|
|
756
|
-
this
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
797
|
-
|
|
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) }
|
|
@@ -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
|
}
|
package/types/client/client.d.ts
CHANGED
|
@@ -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
|
|
87
|
+
createRoom(initialStorage?: object | null): Promise<string>;
|
|
89
88
|
/**
|
|
90
89
|
* Join an existing room
|
|
91
90
|
* @param {string} roomId - ID of the room
|
package/types/server/server.d.ts
CHANGED
|
@@ -21,13 +21,9 @@ export type Room = {
|
|
|
21
21
|
*/
|
|
22
22
|
participants: string[];
|
|
23
23
|
/**
|
|
24
|
-
* - Client ID of the room 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 (
|
|
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
|
|
180
|
+
createRoom(initialStorage?: object | null): {
|
|
186
181
|
state: CRDTState;
|
|
187
182
|
id: string;
|
|
188
183
|
};
|