playsocketjs 3.1.2 → 4.1.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.
@@ -0,0 +1,761 @@
1
+ import { WebSocketServer } from "ws";
2
+ import { createServer } from "node:http";
3
+ import { encode, decode } from "@msgpack/msgpack";
4
+ import CRDTManager, { getUpdateDetails } from "../universal/crdtManager.js";
5
+ import { HEARTBEAT_INTERVAL } from "../universal/constants.js";
6
+
7
+ const MAX_ROOM_SIZE = 500;
8
+ export const RECONNECT_GRACE_PERIOD = 5000; // Exported for use in tests
9
+
10
+ /**
11
+ * @typedef {import("node:http").Server} HttpServer
12
+ * @typedef {import("../universal/crdtManager.js").PropertyUpdateType} PropertyUpdateType
13
+ * @typedef {import("../universal/crdtManager.js").PropertyUpdate} PropertyUpdate
14
+ * @typedef {import("../universal/crdtManager.js").CRDTState} CRDTState
15
+ */
16
+
17
+ /** @typedef {import("ws").WebSocket} WebSocket */ // Includes custom properties from ws-extensions.d.ts
18
+
19
+ /**
20
+ * @typedef {object} ClientMessage
21
+ * @property {string} type - Message type
22
+ */
23
+
24
+ /** @typedef {{ points: number, lastReset: number }} RateLimit */
25
+
26
+ /**
27
+ * @typedef {object} Room
28
+ * @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
31
+ * @property {CRDTManager} crdtManager - CRDT manager holding the room storage
32
+ */
33
+
34
+ /**
35
+ * PlaySocket Server
36
+ */
37
+ export default class PlaySocketServer {
38
+ /** @type {HttpServer} */
39
+ #server;
40
+ #ownsServer = false;
41
+ #rateLimitMaxPoints;
42
+ #wss;
43
+ /** @type {Map<string, WebSocket>} */
44
+ #clients = new Map(); // ClientId -> WebSocket instance
45
+ /** @type {Record<string, Room>} */
46
+ #rooms = {};
47
+ /** @type {Map<string, string>} */
48
+ #clientRooms = new Map(); // ClientId -> RoomId
49
+ /** @type {Map<string, RateLimit>} */
50
+ #rateLimits = new Map(); // Rate limiting storage
51
+ /** @type {Map<string, Function[]>} */
52
+ #callbacks = new Map(); // Event -> [callback functions]
53
+ #heartbeatInterval;
54
+ /** @type {Map<string, { timeout: ReturnType<typeof setTimeout> }>} */
55
+ #pendingDisconnects = new Map(); // ClientId -> {timeout}
56
+ /** @type {Map<string, string>} */
57
+ #clientTokens = new Map(); // ClientId -> Token
58
+ /** @type {Map<string, number>} */
59
+ #roomVersions = new Map(); // RoomId -> Version
60
+
61
+ // Debug
62
+ #debug = false;
63
+
64
+ /**
65
+ * Create a new PlaySocketServer instance
66
+ * @param {object} options - Server configuration options
67
+ * @param {HttpServer} [options.server] - Existing http server
68
+ * @param {number} [options.port=3000] - Port to listen on (if no server provided)
69
+ * @param {string} [options.path='/'] - WebSocket endpoint path
70
+ * @param {boolean} [options.debug=false] - Enable debug logging
71
+ * @param {number} [options.rateLimit=20] - Maximum number of operations per second per client
72
+ * @param {import("ws").ServerOptions["verifyClient"]} [options.verifyClient] - Optional callback to verify client connections before upgrade. Receives (info, callback) where info contains { req, origin } and callback is (verified, code?, message?) => void
73
+ */
74
+ constructor(options = {}) {
75
+ const { server, port = 3000, path = "/", debug = false, rateLimit = 20, verifyClient } = options;
76
+
77
+ if (debug) this.#debug = true; // Enable extra logging
78
+
79
+ this.#rateLimitMaxPoints = rateLimit; // Set rate limit
80
+
81
+ // Handle server creation / usage
82
+ if (server) {
83
+ this.#server = server; // Use provided HTTP server
84
+ } else {
85
+ this.#ownsServer = true;
86
+ this.#server = createServer(); // Create HTTP server and start it
87
+ this.#server.listen(port, () => {
88
+ console.log(`PlaySocket server running on port ${port}.`);
89
+ });
90
+ }
91
+
92
+ // Create WebSocket server
93
+ this.#wss = new WebSocketServer({
94
+ server: this.#server,
95
+ path,
96
+ ...(verifyClient && { verifyClient })
97
+ });
98
+
99
+ // Set up ws event handlers
100
+ this.#wss.on("connection", ws => {
101
+ ws.uuid = crypto.randomUUID();
102
+ ws.isAlive = true;
103
+ ws.on("pong", () => { ws.isAlive = true; });
104
+ ws.on("message", msg => this.#handleMessage(ws, msg));
105
+ ws.on("close", () => this.#handleDisconnection(ws));
106
+ ws.on("error", (error) => {
107
+ console.error(`PlaySocket WebSocket connection error for client ${ws.clientId || "unknown"}:`, error); // Catch conn errors
108
+ });
109
+ });
110
+
111
+ // Log & catch server-level errors
112
+ this.#wss.on("error", (error) => {
113
+ console.error("PlaySocket WebSocket server error:", error);
114
+ });
115
+
116
+ // Start heartbeat
117
+ this.#heartbeatInterval = setInterval(() => {
118
+ this.#wss.clients.forEach(ws => {
119
+ if (!ws.isAlive) return ws.terminate();
120
+ ws.isAlive = false;
121
+ ws.ping();
122
+ });
123
+ }, HEARTBEAT_INTERVAL);
124
+ }
125
+
126
+ /**
127
+ * Handle WebSocket message
128
+ * @param {WebSocket} ws - WebSocket client
129
+ * @param {import("ws").RawData} message - Message encoded with messagepack
130
+ */
131
+ async #handleMessage(ws, message) {
132
+ if (ws.isTerminating) return;
133
+ try {
134
+ const data = /** @type {ClientMessage & Record<string, any>} */ (decode(/** @type {Uint8Array} */(message)));
135
+
136
+ // Apply rate limiting to all connections (including unregistered)
137
+ if (!this.#checkRateLimit(ws.uuid, data.type)) {
138
+ if (!ws.isTerminating) {
139
+ ws.isTerminating = true; // Prevent multiple terminate calls (it is async)
140
+ ws.terminate();
141
+ console.error(`PlaySocket connection ${ws.uuid} terminated due to rate limit violations.`);
142
+ return;
143
+ }
144
+ }
145
+
146
+ switch (data.type) {
147
+ case "register": {
148
+ // Register client ID if provided & check for a duplicate
149
+ if (data.id && (this.#clients.get(data.id) || data.id === "server")) {
150
+ ws.send(encode({ type: "registration_failed", reason: "ID is taken." }), { binary: true });
151
+ return;
152
+ }
153
+
154
+ // Generate client ID if none provided
155
+ if (!data.id) {
156
+ for (let i = 0; i < 50; i++) {
157
+ const id = this.#generateId();
158
+ if (!this.#clients.get(id)) {
159
+ data.id = id;
160
+ break;
161
+ }
162
+ }
163
+ if (!data.id) {
164
+ ws.send(encode({ type: "registration_failed", reason: "No available ID found." }), { binary: true });
165
+ throw new Error("Failed to generate unique ID!");
166
+ }
167
+ }
168
+
169
+ // Event callback
170
+ const registrationAllowed = await this.#triggerEvent("clientRegistrationRequested", data.id, data.customData);
171
+ if (registrationAllowed === false || typeof registrationAllowed === "string") {
172
+ ws.send(encode({
173
+ type: "registration_failed",
174
+ reason: typeof registrationAllowed === "string" ? registrationAllowed : null
175
+ }), { binary: true });
176
+ return;
177
+ }
178
+
179
+ ws.clientId = data.id; // Adds the provided id to the ws object as clientId
180
+ this.#clients.set(data.id, ws);
181
+ const sessionToken = this.#generateSessionToken();
182
+ this.#clientTokens.set(data.id, sessionToken); // Token is used when reconnecting to prevent impersonation attacks
183
+ ws.send(encode({ type: "registered", id: data.id, sessionToken }), { binary: true });
184
+ this.#triggerEvent("clientRegistered", data.id, data.customData);
185
+ break;
186
+ }
187
+
188
+ case "reconnect": {
189
+ // If user is pending disconnect, respond (otherwise it's too late)
190
+ const pd = this.#pendingDisconnects.get(data.id);
191
+ if (pd && data.sessionToken) {
192
+ if (data.sessionToken !== this.#clientTokens.get(data.id)) {
193
+ ws.send(encode({
194
+ type: "reconnection_failed",
195
+ reason: "Session token does not match."
196
+ }), { binary: true });
197
+ return;
198
+ }
199
+ clearTimeout(pd.timeout);
200
+ this.#pendingDisconnects.delete(data.id);
201
+
202
+ // Re-assign old client ID to the new ws connection
203
+ ws.clientId = data.id;
204
+ this.#clients.set(data.id, ws);
205
+
206
+ // If they were in a room, provide updated room data
207
+ let roomData;
208
+ const formerRoomId = this.#clientRooms.get(data.id);
209
+ const formerRoom = this.#rooms[formerRoomId];
210
+ if (formerRoom) {
211
+ // If the room has no host (they were the only one and disconnected), restore them as host
212
+ if (formerRoom.host === null) formerRoom.host = data.id;
213
+ if (this.#debug) console.log(`State sent for reconnection for room ${formerRoomId}:`, formerRoom.crdtManager.state);
214
+ roomData = {
215
+ state: formerRoom.crdtManager.state,
216
+ participantCount: formerRoom.participants.length,
217
+ host: formerRoom.host,
218
+ version: this.#roomVersions.get(formerRoomId)
219
+ }
220
+ }
221
+
222
+ ws.send(encode({ type: "reconnected", roomData }), { binary: true });
223
+ } else {
224
+ ws.send(encode({ type: "reconnection_failed", reason: "Client unknown to server." }), { binary: true });
225
+ }
226
+ break;
227
+ }
228
+
229
+ case "create_room": {
230
+ if (!ws.clientId) return;
231
+
232
+ if (this.#clientRooms.get(ws.clientId)) {
233
+ ws.send(encode({
234
+ type: "room_creation_failed",
235
+ reason: "Already in a room."
236
+ }), { binary: true });
237
+ return;
238
+ }
239
+
240
+ // Event callback with potential initial storage modifications
241
+ const reviewedStorage = await this.#triggerEvent("roomCreationRequested", { clientId: ws.clientId, initialStorage: structuredClone({ ...data.initialStorage }) });
242
+ if (typeof reviewedStorage === "object") data.initialStorage = reviewedStorage;
243
+ if (reviewedStorage === false || typeof reviewedStorage === "string") {
244
+ ws.send(encode({ type: "room_creation_failed", reason: typeof reviewedStorage === "string" ? reviewedStorage : null }), { binary: true });
245
+ return;
246
+ }
247
+
248
+ try {
249
+ // Verify client is still connected and abort if not
250
+ if (!this.#clients.has(ws.clientId)) {
251
+ if (this.#debug) console.log(`Room creation cancelled - client ${ws.clientId} disconnected during event callback.`);
252
+ return;
253
+ }
254
+
255
+ const newRoom = this.createRoom(data.initialStorage, data.size, ws.clientId); // Create room
256
+
257
+ this.#rooms[newRoom.id].participants.push(ws.clientId) // Add client to the room
258
+ this.#clientRooms.set(ws.clientId, newRoom.id); // Add client to the client-room map
259
+ ws.send(encode({ type: "room_created", state: newRoom.state, roomId: newRoom.id, participantCount: this.#rooms[newRoom.id].participants.length }), { binary: true });
260
+ } catch (error) {
261
+ console.error("PlaySocket error creating room:", error);
262
+ ws.send(encode({ type: "room_creation_failed", reason: error.message }), { binary: true });
263
+ }
264
+ break;
265
+ }
266
+
267
+ case "join_room": {
268
+ if (!data.roomId || !ws.clientId) return;
269
+ const roomId = data.roomId;
270
+
271
+ /** @param {string} reason */
272
+ const rejectJoin = (reason) => {
273
+ ws.send(encode({ type: "join_rejected", reason }), { binary: true });
274
+ };
275
+
276
+ // Event callback
277
+ const joinAllowed = await this.#triggerEvent("clientJoinRequested", ws.clientId, roomId);
278
+ if (joinAllowed === false || typeof joinAllowed === "string") return rejectJoin(typeof joinAllowed === "string" ? joinAllowed : null);
279
+
280
+ // Verify client is still connected and abort if not
281
+ if (!this.#clients.has(ws.clientId)) {
282
+ if (this.#debug) console.log(`Room join cancelled - client ${ws.clientId} disconnected during event callback.`);
283
+ return;
284
+ }
285
+
286
+ try {
287
+ this.#joinRoom(ws.clientId, roomId);
288
+ } catch (error) {
289
+ rejectJoin(error.message);
290
+ }
291
+
292
+ break;
293
+ }
294
+
295
+ case "update_property": {
296
+ const roomId = this.#clientRooms.get(ws.clientId);
297
+ const room = roomId ? this.#rooms[roomId] : null;
298
+
299
+ if (room && data.update) {
300
+ // Check if update is allowed via event callback (provide clone to ensure update integrity)
301
+ const updateAllowed = await this.#triggerEvent("storageUpdateRequested", { roomId, clientId: ws.clientId, update: structuredClone(data.update), storage: this.getRoomStorage(roomId) });
302
+ if (updateAllowed === false || typeof updateAllowed === "string") {
303
+ ws.send(encode({
304
+ type: "property_update_rejected",
305
+ reason: typeof updateAllowed === "string" ? updateAllowed : null,
306
+ update: data.update
307
+ }), { binary: true });
308
+ return;
309
+ }
310
+
311
+ room.crdtManager.importPropertyUpdate(data.update); // Import update into server state
312
+
313
+ // Increment version for this room
314
+ const currentVersion = this.#roomVersions.get(roomId) + 1;
315
+ this.#roomVersions.set(roomId, currentVersion);
316
+
317
+ room.participants?.forEach(p => {
318
+ const client = this.#clients.get(p);
319
+ if (client) {
320
+ client.send(encode({
321
+ type: "property_updated",
322
+ update: data.update,
323
+ version: currentVersion
324
+ }), { binary: true });
325
+ }
326
+ });
327
+
328
+ this.#triggerEvent("storageUpdated", { roomId, clientId: ws.clientId, update: structuredClone(data.update), storage: this.getRoomStorage(roomId) });
329
+ if (this.#debug) console.log("Property update received and imported:", data.update);
330
+ }
331
+ break;
332
+ }
333
+
334
+ case "request": {
335
+ if (!ws.clientId) return;
336
+ const roomId = this.#clientRooms.get(ws.clientId) || null;
337
+ const requestSuccess = await this.#triggerEvent("requestReceived", { roomId, clientId: ws.clientId, name: data.request.name, data: data.request.data });
338
+ if (requestSuccess === false || typeof requestSuccess === "string") ws.send(encode({ type: "request_failed", request: data.request, reason: typeof requestSuccess === "string" ? requestSuccess : null }), { binary: true });
339
+ else ws.send(encode({ type: "request_succeeded", request: data.request }), { binary: true });
340
+ break;
341
+ }
342
+
343
+ case "disconnect":
344
+ // Client signals to server that it will will willfully disconnect soon
345
+ ws.willfulDisconnect = true;
346
+ break;
347
+ }
348
+ } catch (error) {
349
+ console.error("PlaySocket error in message handler:", error);
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Generate a readable, 6 digit ID
355
+ * @returns {string} - Id
356
+ */
357
+ #generateId() {
358
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789";
359
+ return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
360
+ }
361
+
362
+ /**
363
+ * Migrate the host role to another participant
364
+ * @param {string} roomId - ID of the room
365
+ * @param {string} clientId - ID of the current host
366
+ */
367
+ #migrateHost(roomId, clientId) {
368
+ const room = this.#rooms[roomId];
369
+ if (!room) throw new Error("Room not found.");
370
+ if (room.host !== clientId) throw new Error("Client is not the host.");
371
+
372
+ const remainingParticipants = room.participants.filter(p => p !== clientId);
373
+
374
+ if (remainingParticipants.length >= 1) {
375
+ // Inform all participants about the new host
376
+ room.host = remainingParticipants[0];
377
+ remainingParticipants.forEach(p => {
378
+ const client = this.#clients.get(p);
379
+ if (client) client.send(encode({
380
+ type: "host_migrated",
381
+ newHost: room.host,
382
+ }), { binary: true });
383
+ });
384
+ } else room.host = null; // The next person to join will become the host
385
+ }
386
+
387
+ /**
388
+ * Generate a random token to prevent malicious reconnect attempts
389
+ * @returns {string} - Token
390
+ */
391
+ #generateSessionToken() {
392
+ let token = "";
393
+ for (let i = 0; i < 16; i++) token += Math.floor(Math.random() * 16).toString(16);
394
+ return token;
395
+ }
396
+
397
+ /**
398
+ * Check rate limit using token bucket algorithm
399
+ * @param {string} connUuid - Connection UUID (which is different from the client ID)
400
+ * @param {string} actionType - A string describing the action
401
+ * @returns {boolean} - Whether or not the action can be allowed, true means allowed, false means limited
402
+ */
403
+ #checkRateLimit(connUuid, actionType) {
404
+ const now = Date.now();
405
+
406
+ if (!this.#rateLimits.has(connUuid)) {
407
+ this.#rateLimits.set(connUuid, { points: this.#rateLimitMaxPoints, lastReset: now });
408
+ return true;
409
+ }
410
+
411
+ const limit = this.#rateLimits.get(connUuid);
412
+
413
+ // Reset points if interval has passed (1s)
414
+ if (now - limit.lastReset > 1000) {
415
+ limit.points = this.#rateLimitMaxPoints;
416
+ limit.lastReset = now;
417
+ }
418
+
419
+ const pointCost = actionType == "create_room" ? 5 : 1;
420
+ if (limit.points < pointCost) return false;
421
+
422
+ limit.points -= pointCost;
423
+ return true;
424
+ }
425
+
426
+ /**
427
+ * Handle client disconnection
428
+ * @param {WebSocket} ws - WebSocket client
429
+ */
430
+ #handleDisconnection(ws) {
431
+ this.#rateLimits.delete(ws.uuid);
432
+
433
+ if (ws.clientId) {
434
+ this.#clients.delete(ws.clientId); // Immediately remove from active clients (otherwise, server would try to message this client)
435
+
436
+ // If client was in a room, check if they were the host & migrate
437
+ const roomId = this.#clientRooms.get(ws.clientId);
438
+ if (roomId != null && this.#rooms[roomId]?.host === ws.clientId) this.#migrateHost(roomId, ws.clientId);
439
+
440
+ if (ws.willfulDisconnect) this.#disconnectClient(ws); // Immediate disconnection
441
+ else {
442
+ // Pending complete disconnection with 5s grace period to allow for reconnections
443
+ this.#pendingDisconnects.set(ws.clientId, {
444
+ timeout: setTimeout(() => {
445
+ this.#disconnectClient(ws);
446
+ }, RECONNECT_GRACE_PERIOD)
447
+ });
448
+ }
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Disconnect a client
454
+ * @param {WebSocket} ws - WebSocket client
455
+ */
456
+ #disconnectClient(ws) {
457
+ this.#pendingDisconnects.delete(ws.clientId);
458
+ this.#clientTokens.delete(ws.clientId);
459
+ const roomId = this.#clientRooms.get(ws.clientId);
460
+ if (roomId && this.#rooms[roomId]) this.#leaveRoom(ws.clientId, roomId);
461
+ this.#triggerEvent("clientDisconnected", ws.clientId);
462
+ }
463
+
464
+ /**
465
+ * Register an event callback
466
+ * @param {string} event - Event name
467
+ * @param {Function} callback - Callback function
468
+ * @returns {Function} - Unsubscribe
469
+ */
470
+ onEvent(event, callback) {
471
+ const validEvents = ["clientRegistered", "clientRegistrationRequested", "clientDisconnected", "clientJoinedRoom", "clientLeftRoom", "clientJoinRequested", "roomCreated", "roomCreationRequested", "requestReceived", "storageUpdated", "storageUpdateRequested", "roomDestroyed"];
472
+ if (!validEvents.includes(event)) {
473
+ console.warn(`Invalid PlaySocket event type "${event}"`);
474
+ return () => { };
475
+ };
476
+ if (!this.#callbacks.has(event)) this.#callbacks.set(event, []);
477
+ this.#callbacks.get(event).push(callback);
478
+
479
+ return () => {
480
+ const removeIndex = this.#callbacks.get(event)?.indexOf(callback) ?? -1;
481
+ if (removeIndex === -1) return;
482
+ this.#callbacks.get(event).splice(removeIndex, 1);
483
+ if (!this.#callbacks.get(event).length) this.#callbacks.delete(event);
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Trigger an event to registered callbacks
489
+ * @param {string} event - Event name
490
+ * @param {...*} args - Arguments
491
+ * @returns {Promise<any>} - The first non-null callback return value if present (all callbacks run, a throwing callback counts as returning false), otherwise true
492
+ */
493
+ async #triggerEvent(event, ...args) {
494
+ const syncOnlyEvents = ["storageUpdateRequested"]; // Async storage validation could mess up GC and would lead to potentially poor UX (slow sync)
495
+ const callbacks = this.#callbacks.get(event);
496
+ if (!callbacks) return;
497
+
498
+ let firstResult;
499
+ for (const callback of [...callbacks]) {
500
+ try {
501
+ let result = callback(...args);
502
+ if (typeof result?.then === "function") {
503
+ if (syncOnlyEvents.includes(event)) {
504
+ result.catch(() => { });
505
+ console.error(`PlaySocket ${event} callbacks must be synchronous.`);
506
+ result = `${event} callback must be synchronous.`; // Rejection string to explicitly block this practice
507
+ } else result = await result;
508
+ }
509
+ if (firstResult == null && result != null) firstResult = result;
510
+ } catch (error) {
511
+ console.error(`PlaySocket ${event} callback error:`, error);
512
+ if (firstResult == null) firstResult = false; // Fail closed (false blocks for most events)
513
+ }
514
+ }
515
+ return firstResult ?? true;
516
+ }
517
+
518
+ /**
519
+ * Kick a client from the server
520
+ * @param {string} clientId - Client ID
521
+ * @param {string} [reason] - Optional reason
522
+ */
523
+ kick(clientId, reason = null) {
524
+ const client = this.#clients.get(clientId);
525
+ if (!client) return; // Client not connected, skip
526
+ client.willfulDisconnect = true;
527
+ client.send(encode({ type: "kicked", reason }), { binary: true });
528
+ client.close();
529
+ }
530
+
531
+ /**
532
+ * Move client into a different room
533
+ * @param {string} clientId - Client ID
534
+ * @param {string} roomId - Room ID (Target)
535
+ */
536
+ move(clientId, roomId) {
537
+ const client = this.#clients.get(clientId);
538
+ const oldRoomId = this.#clientRooms.get(clientId);
539
+ const oldRoom = this.#rooms[oldRoomId];
540
+ const targetRoom = this.#rooms[roomId];
541
+ if (!client && !this.#pendingDisconnects.has(clientId)) throw new Error("Client not found.");
542
+ if (!oldRoomId) throw new Error("Client is not in a room.");
543
+ if (!oldRoom) throw new Error("Client room not found.");
544
+ if (oldRoomId === roomId) throw new Error("Client is already in target room.");
545
+ if (!targetRoom) throw new Error("Target room not found.");
546
+ if (targetRoom.participants.length >= targetRoom.size) throw new Error("Target room is full.");
547
+ if (oldRoom.host === clientId) this.#migrateHost(oldRoomId, clientId);
548
+ this.#leaveRoom(clientId, oldRoomId);
549
+ this.#joinRoom(clientId, roomId);
550
+ }
551
+
552
+ /**
553
+ * Get snapshot of a room's storage
554
+ * @param {string} roomId - ID of the room to get the storage from
555
+ * @returns {Record<string, any> | undefined} - Storage object or undefined if the room doesn't exist
556
+ */
557
+ getRoomStorage(roomId) {
558
+ const room = this.#rooms[roomId];
559
+ if (room) return room.crdtManager.propertyStore;
560
+ }
561
+
562
+ /**
563
+ * Get the operation details from a storage update (e.g. in the "storageUpdateRequested" event)
564
+ * @param {PropertyUpdate} update - Property update
565
+ * @returns {{key: string | undefined, type: PropertyUpdateType | undefined, value: *, secondValue: *}} - Operation details
566
+ */
567
+ getUpdateDetails(update) {
568
+ return getUpdateDetails(update);
569
+ }
570
+
571
+ /**
572
+ * Update a value in a room's storage
573
+ * @param {string} roomId - Room ID
574
+ * @param {string} key - Storage key
575
+ * @param {PropertyUpdateType} type - Operation type
576
+ * @param {*} value - Value
577
+ * @param {*} [secondValue] - Second value (needed for some operations)
578
+ */
579
+ updateRoomStorage(roomId, key, type, value, secondValue) {
580
+ if (this.#debug) console.log(`Playsocket server property update for room ${roomId}, key ${key}, operation ${type}, value ${value} and secondValue ${secondValue}.`);
581
+ const room = this.#rooms[roomId];
582
+ if (room) {
583
+ const propertyUpdate = room.crdtManager.updateProperty(key, type, value, secondValue);
584
+ const currentVersion = this.#roomVersions.get(roomId) + 1;
585
+ this.#roomVersions.set(roomId, currentVersion); // Increment version for this room
586
+
587
+ room.participants?.forEach(p => {
588
+ const client = this.#clients.get(p);
589
+ if (client) {
590
+ client.send(encode({
591
+ type: "property_updated",
592
+ update: propertyUpdate,
593
+ version: currentVersion
594
+ }), { binary: true });
595
+ }
596
+ });
597
+ this.#triggerEvent("storageUpdated", { roomId, clientId: null, update: structuredClone(propertyUpdate), storage: this.getRoomStorage(roomId) });
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Create a room
603
+ * @param {object} [initialStorage] - Optional initial storage object
604
+ * @param {number} [size] - Max. room size, up to 500
605
+ * @param {string} [host] - Host ID, defaults to "server" (when set to "server", room will not be deleted if all clients leave)
606
+ * @returns {{ state: CRDTState, id: string }} Object containing room state and room ID
607
+ */
608
+ createRoom(initialStorage, size, host = "server") {
609
+ let newRoomId;
610
+
611
+ for (let i = 0; i < 100; i++) {
612
+ const id = this.#generateId();
613
+ if (!this.#rooms[id]) {
614
+ newRoomId = id;
615
+ break;
616
+ }
617
+ }
618
+
619
+ if (!newRoomId) throw new Error("No available ID found.");
620
+ const roomCrdtManager = new CRDTManager(this.#debug);
621
+
622
+ if (initialStorage) Object.entries(initialStorage)?.forEach(([key, value]) => {
623
+ roomCrdtManager.updateProperty(key, "set", value);
624
+ });
625
+
626
+ this.#roomVersions.set(newRoomId, 0);
627
+ this.#rooms[newRoomId] = {
628
+ participants: [],
629
+ host,
630
+ size: Math.min(Number(size), MAX_ROOM_SIZE) || MAX_ROOM_SIZE,
631
+ crdtManager: roomCrdtManager
632
+ };
633
+
634
+ this.#triggerEvent("roomCreated", newRoomId);
635
+ if (this.#debug) console.log(`Room ${newRoomId} created with initial storage:`, initialStorage);
636
+ return { state: roomCrdtManager.state, id: newRoomId };
637
+ }
638
+
639
+ /**
640
+ * Make a client join a room
641
+ * @param {string} clientId - Client ID
642
+ * @param {string} roomId - Room ID
643
+ */
644
+ #joinRoom(clientId, roomId) {
645
+ const room = this.#rooms[roomId];
646
+ const client = this.#clients.get(clientId);
647
+ if (!room) throw new Error("Room not found.");
648
+ if (!client && !this.#pendingDisconnects.has(clientId)) throw new Error("Client not found.");
649
+ if (this.#clientRooms.get(clientId)) throw new Error("Already in a room.");
650
+ if (room.participants.length >= room.size) throw new Error("Room full.");
651
+
652
+ room.participants.push(clientId);
653
+ this.#clientRooms.set(clientId, roomId);
654
+
655
+ // Notify joiner with initial storage (only if connected - pending disconnect clients get state on reconnect)
656
+ if (client) {
657
+ if (this.#debug) console.log("Room state sent for join:", room.crdtManager.state);
658
+ client.send(encode({
659
+ type: "join_accepted",
660
+ roomId,
661
+ state: room.crdtManager.state,
662
+ participantCount: room.participants.length,
663
+ host: room.host,
664
+ version: this.#roomVersions.get(roomId)
665
+ }), { binary: true });
666
+ }
667
+
668
+ // If the room has no host (previous host disconnected and was the only participant), make joiner the host
669
+ // Set new host after join_accepted so hostMigrated event fires correctly on client
670
+ const becameHost = room.host === null;
671
+ if (becameHost) {
672
+ room.host = clientId; // Change host
673
+ if (client) {
674
+ client.send(encode({
675
+ type: "host_migrated",
676
+ newHost: room.host,
677
+ }), { binary: true });
678
+ }
679
+ }
680
+
681
+ // Notify existing participants
682
+ room.participants.forEach(p => {
683
+ if (p === clientId) return;
684
+ const client = this.#clients.get(p);
685
+ if (client) {
686
+ client.send(encode({
687
+ type: "client_joined",
688
+ client: clientId,
689
+ participantCount: room.participants.length
690
+ }), { binary: true });
691
+ }
692
+ });
693
+ this.#triggerEvent("clientJoinedRoom", clientId, roomId);
694
+ }
695
+
696
+ /**
697
+ * Make a client leave a room
698
+ * @param {string} clientId - Client ID
699
+ * @param {string} roomId - Room ID
700
+ */
701
+ #leaveRoom(clientId, roomId) {
702
+ const room = this.#rooms[roomId];
703
+ if (!room) throw new Error("Room not found.");
704
+
705
+ room.participants = room.participants.filter(p => p !== clientId); // Remove client from room
706
+ this.#clientRooms.delete(clientId);
707
+ this.#triggerEvent("clientLeftRoom", clientId, roomId); // Before potential room destruction to allow for accessing its data in callback
708
+
709
+ if (room.participants.length === 0 && room.host !== "server") {
710
+ this.destroyRoom(roomId); // Destroy room if now empty & not by server
711
+ } else {
712
+ // Notify remaining participants
713
+ room.participants.forEach(p => {
714
+ const client = this.#clients.get(p);
715
+ if (client) client.send(encode({
716
+ type: "client_left",
717
+ client: clientId,
718
+ participantCount: room.participants.length
719
+ }), { binary: true });
720
+ });
721
+ }
722
+ }
723
+
724
+ /**
725
+ * Destroy a room
726
+ * @param {string} roomId - Room ID
727
+ */
728
+ destroyRoom(roomId) {
729
+ const room = this.#rooms[roomId];
730
+ if (!room) throw new Error("Room not found.");
731
+
732
+ // Disconnect clients if still in room
733
+ const participants = room.participants;
734
+ participants.forEach((clientId) => {
735
+ this.kick(clientId, "Room destroyed by server.");
736
+ });
737
+
738
+ // Delete the room
739
+ delete this.#rooms[roomId];
740
+ this.#roomVersions.delete(roomId); // Delete room version (used to ensure all clients are up-2-date)
741
+ this.#triggerEvent("roomDestroyed", roomId);
742
+ if (this.#debug) console.log("Deleted room with id " + roomId + ".");
743
+ }
744
+
745
+ /**
746
+ * Close all client connections, then close the websocket and http server
747
+ */
748
+ stop() {
749
+ clearInterval(this.#heartbeatInterval);
750
+ if (this.#wss) {
751
+ this.#clients.forEach((client, clientId) => this.kick(clientId, "Server restart."));
752
+ this.#wss.close();
753
+ }
754
+ if (this.#server && this.#ownsServer) this.#server.close(() => { console.log("PlaySocket server stopped."); });
755
+ this.#pendingDisconnects.forEach((data) => {
756
+ clearTimeout(data.timeout);
757
+ });
758
+ }
759
+
760
+ get rooms() { return structuredClone(this.#rooms) }
761
+ }