playsocketjs 3.1.1 → 4.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.
@@ -0,0 +1,555 @@
1
+ import { encode, decode } from "@msgpack/msgpack";
2
+ import CRDTManager from "../universal/crdtManager.js";
3
+
4
+ const ERROR_PREFIX = "PlaySocket error: ";
5
+ const WARNING_PREFIX = "PlaySocket warning: ";
6
+ const LOG_PREFIX = "PlaySocket log: ";
7
+ const TIMEOUT_MS = 3000; // 3 second timeout for WS messages
8
+
9
+ /** @typedef {import("../universal/crdtManager.js").PropertyUpdateType} PropertyUpdateType */
10
+
11
+ /**
12
+ * @typedef {object} PlaySocketOptions
13
+ * @property {string} endpoint - WebSocket server endpoint (e.g. 'wss://example.com/socket')
14
+ * @property {object} [customData] - Custom registration data
15
+ * @property {boolean} [debug=false] - Enable debug logging
16
+ */
17
+
18
+ /**
19
+ * @typedef {object} ServerMessage
20
+ * @property {string} type - Message type
21
+ */
22
+
23
+ /** @typedef {{ resolve: (value?: any) => void, reject: (reason?: any) => void }} PendingPromise */
24
+
25
+ /**
26
+ * PlaySocket Client
27
+ */
28
+ export default class PlaySocket {
29
+ // Core properties
30
+ /** @type {string | undefined} */
31
+ #id; // Unique client ID
32
+ /** @type {string | undefined} */
33
+ #sessionToken; // Unique session token
34
+ /** @type {string | undefined} */
35
+ #endpoint;
36
+ /** @type {WebSocket | null} */
37
+ #socket = null; // WebSocket connection
38
+ #initialized = false; // Initialization status
39
+ /** @type {object | undefined} */
40
+ #customData;
41
+
42
+ // Room properties
43
+ /** @type {string | null | undefined} */
44
+ #roomHost;
45
+ /** @type {boolean | undefined} */
46
+ #inRoom; // If the client is currently in a room or not
47
+ #participantCount = 0;
48
+ #crdtManager;
49
+ #roomVersion = 0; // Update version (used to compare local vs. remote state to detect package loss)
50
+
51
+ // Event handling
52
+ /** @type {Map<string, Function[]>} */
53
+ #callbacks = new Map(); // Event callbacks
54
+
55
+ // Async server operations
56
+ /** @type {PendingPromise | null} */
57
+ #pendingJoin = null;
58
+ /** @type {PendingPromise | null} */
59
+ #pendingCreate = null;
60
+ /** @type {PendingPromise | null} */
61
+ #pendingRegistration = null;
62
+ /** @type {Pick<PendingPromise, "reject"> | null} */
63
+ #pendingConnect = null;
64
+ /** @type {PendingPromise | null} */
65
+ #pendingReconnect = null;
66
+ /** @type {Map<string, PendingPromise>} */
67
+ #pendingRequests = new Map();
68
+
69
+ // Timeouts or intervals
70
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
71
+ #reconnectTimeout;
72
+ #reconnectCount = 0;
73
+ #isReconnecting = false;
74
+
75
+ // Debug
76
+ #debug = false;
77
+
78
+ /**
79
+ * Create a new PlaySocket instance
80
+ * @param {string} [id] - Unique identifier for this client (assigned by the server if omitted)
81
+ * @param {PlaySocketOptions} options - Connection options
82
+ */
83
+ constructor(id, options = /** @type {PlaySocketOptions} */ ({})) {
84
+ this.#id = id;
85
+ if (options.endpoint) this.#endpoint = options.endpoint;
86
+ if (options.customData) this.#customData = { ...options.customData };
87
+ if (options.debug) this.#debug = true; // Enabling extra logging
88
+ this.#crdtManager = new CRDTManager(this.#debug);
89
+ }
90
+
91
+ /**
92
+ * Helper to create timeout promises for create room, join room etc.
93
+ * @param {string} name - Name of the timeout
94
+ * @returns {Promise<never>} - Promise that rejects after the timeout
95
+ */
96
+ #createTimeout(name) {
97
+ return new Promise((_, reject) =>
98
+ setTimeout(() => reject(new Error(`${name} timed out.`)), TIMEOUT_MS)
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Register an event callback
104
+ * @param {string} event - Event name
105
+ * @param {Function} callback - Callback function
106
+ * @returns {Function} - Unsubscribe
107
+ */
108
+ onEvent(event, callback) {
109
+ const validEvents = ["status", "error", "moved", "instanceDestroyed", "storageUpdated", "hostMigrated", "clientJoined", "clientLeft"];
110
+ if (!validEvents.includes(event)) {
111
+ console.warn(WARNING_PREFIX + `Invalid event type "${event}".`);
112
+ return () => { };
113
+ };
114
+ if (!this.#callbacks.has(event)) this.#callbacks.set(event, []);
115
+ this.#callbacks.get(event).push(callback);
116
+
117
+ return () => {
118
+ const removeIndex = this.#callbacks.get(event)?.indexOf(callback) ?? -1;
119
+ if (removeIndex === -1) return;
120
+ this.#callbacks.get(event).splice(removeIndex, 1);
121
+ if (!this.#callbacks.get(event).length) this.#callbacks.delete(event);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Trigger an event to registered callbacks
127
+ * @param {string} event - Event name
128
+ * @param {...*} args - Arguments
129
+ */
130
+ #triggerEvent(event, ...args) {
131
+ const callbacks = this.#callbacks.get(event);
132
+ if (!callbacks) return;
133
+ [...callbacks].forEach(callback => {
134
+ try {
135
+ callback(...args);
136
+ } catch (error) {
137
+ console.error(ERROR_PREFIX + `${event} callback error:`, error);
138
+ }
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Initialize the PlaySocket instance by connecting to the WS server
144
+ * @returns {Promise<string>} Resolves with the client ID when the connection is established
145
+ */
146
+ async init() {
147
+ if (this.#initialized) throw new Error("Already initialized!");
148
+ if (!this.#endpoint) throw new Error("No websocket endpoint provided!");
149
+ this.#triggerEvent("status", "Initializing...");
150
+
151
+ // Connect to WS server
152
+ await this.#connect();
153
+
154
+ // Register with server
155
+ const id = /** @type {string | undefined} */ (await Promise.race([
156
+ new Promise((resolve, reject) => {
157
+ this.#pendingRegistration = { resolve, reject }; // Resolve or reject depending on answer to registration msg
158
+
159
+ // Register with server
160
+ this.#sendToServer({
161
+ type: "register",
162
+ id: this.#id,
163
+ customData: this.#customData || undefined
164
+ });
165
+ }),
166
+ this.#createTimeout("Registration")
167
+ ]).finally(() => {
168
+ this.#pendingRegistration = null;
169
+ }));
170
+
171
+ // If the server sent out an id, store and return that
172
+ if (id) this.#id = id;
173
+ return this.#id;
174
+ }
175
+
176
+ /**
177
+ * Connect to the WS server and
178
+ * @returns {Promise<*>} Resolves when the connection was established
179
+ */
180
+ async #connect() {
181
+ return Promise.race([
182
+ new Promise((resolve, reject) => {
183
+ this.#pendingConnect = { reject };
184
+ this.#socket = new WebSocket(this.#endpoint);
185
+ this.#socket.binaryType = "arraybuffer"; // Important for MessagePack binary data
186
+ this.#setupSocketHandlers(); // Message & close events
187
+ this.#socket.onopen = resolve;
188
+ }),
189
+ this.#createTimeout("Connection attempt")
190
+ ]).finally(() => {
191
+ this.#pendingConnect = null;
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Set up WebSocket message & close handlers
197
+ */
198
+ #setupSocketHandlers() {
199
+ this.#socket.onmessage = (event) => {
200
+ try {
201
+ const message = /** @type {ServerMessage & Record<string, any>} */ (decode(new Uint8Array(event.data)));
202
+ if (!message.type) return;
203
+
204
+ switch (message.type) {
205
+ case "registration_failed":
206
+ this.#triggerEvent("error", "Failed to register: " + (message.reason || "No reason provided."));
207
+ if (this.#pendingRegistration) this.#pendingRegistration.reject(new Error("Failed to register: " + (message.reason || "No reason provided.")));
208
+ break;
209
+
210
+ case "registered":
211
+ this.#sessionToken = message.sessionToken;
212
+ this.#initialized = true;
213
+ if (this.#pendingRegistration) this.#pendingRegistration.resolve(message.id);
214
+ this.#triggerEvent("status", "Connected to server.");
215
+ break;
216
+
217
+ case "join_accepted":
218
+ if (this.#debug) console.log(LOG_PREFIX + "State received for join:", message.state);
219
+ this.#inRoom = true;
220
+ this.#crdtManager.importState(message.state);
221
+ this.#participantCount = message.participantCount;
222
+ this.#roomHost = message.host;
223
+ this.#roomVersion = message.version;
224
+ this.#triggerEvent("storageUpdated", this.storage);
225
+ if (this.#pendingJoin) {
226
+ this.#triggerEvent("status", "Connected to room.");
227
+ this.#pendingJoin.resolve();
228
+ } else {
229
+ this.#triggerEvent("status", "Moved to room.");
230
+ this.#triggerEvent("moved", message.roomId);
231
+ }
232
+ break;
233
+
234
+ case "join_rejected":
235
+ this.#triggerEvent("status", "Failed to join room: " + (message.reason || "No reason provided."));
236
+ if (this.#pendingJoin) this.#pendingJoin.reject(new Error("Failed to join room: " + (message.reason || "No reason provided.")));
237
+ break;
238
+
239
+ case "reconnected":
240
+ this.#isReconnecting = false;
241
+ this.#reconnectCount = 0;
242
+ if (message.roomData) {
243
+ if (this.#debug) console.log(LOG_PREFIX + "State received for reconnect:", message.roomData.state);
244
+ this.#crdtManager.importState(message.roomData.state);
245
+ this.#roomVersion = message.roomData.version;
246
+ this.#participantCount = message.roomData.participantCount;
247
+ this.#setHost(message.roomData.host); // Set host before in case there are .isHost checks in the storageUpdate fallback
248
+ this.#triggerEvent("storageUpdated", this.storage);
249
+ } else if (this.#inRoom) {
250
+ // If no room data received, but client thinks they were in a room...
251
+ this.#triggerEvent("error", "Reconnected, but room no longer exists.");
252
+ return this.destroy();
253
+ }
254
+ this.#triggerEvent("status", "Reconnected.");
255
+ if (this.#pendingReconnect) this.#pendingReconnect.resolve();
256
+ break;
257
+
258
+ case "reconnection_failed":
259
+ if (this.#pendingReconnect) this.#pendingReconnect.reject(new Error("Server rejected reconnection: " + message.reason));
260
+ break;
261
+
262
+ case "room_created":
263
+ this.#inRoom = true;
264
+ this.#participantCount = message.participantCount;
265
+ this.#triggerEvent("status", "Room created.");
266
+ this.#crdtManager.importState(message.state);
267
+ this.#triggerEvent("storageUpdated", this.storage);
268
+ if (this.#pendingCreate) this.#pendingCreate.resolve(message.roomId);
269
+ break;
270
+
271
+ case "room_creation_failed":
272
+ this.#triggerEvent("error", "Failed to create room: " + (message.reason || "No reason provided."));
273
+ if (this.#pendingCreate) this.#pendingCreate.reject(new Error("Failed to create room: " + (message.reason || "No reason provided.")));
274
+ break;
275
+
276
+ case "property_updated":
277
+ this.#roomVersion++; // Increment room version
278
+ if (this.#debug) console.log(LOG_PREFIX + "Property update received:", message.update);
279
+ this.#crdtManager.importPropertyUpdate(message.update);
280
+ if (this.#crdtManager.didPropertiesChange) this.#triggerEvent("storageUpdated", this.storage);
281
+ if (this.#roomVersion != message.version && this.#initialized && this.#socket?.readyState === WebSocket.OPEN) {
282
+ this.#triggerEvent("error", "Detected skipped update – forcing reconnect.");
283
+ this.#socket?.close();
284
+ }
285
+ break;
286
+
287
+ case "property_update_rejected":
288
+ this.#triggerEvent("error", "Property update rejected, reverting: " + (message.reason || "No reason provided."));
289
+ this.#crdtManager.revertPropertyUpdate(message.update);
290
+ if (this.#crdtManager.didPropertiesChange) this.#triggerEvent("storageUpdated", this.storage);
291
+ break;
292
+
293
+ case "kicked":
294
+ this.#triggerEvent("error", `Kicked out of room: ${message.reason || "No reason provided."}`);
295
+ this.destroy();
296
+ break;
297
+
298
+ case "host_migrated":
299
+ this.#setHost(message.newHost);
300
+ break;
301
+
302
+ case "client_left":
303
+ this.#participantCount = message.participantCount;
304
+ this.#triggerEvent("clientLeft", message.client);
305
+ this.#triggerEvent("status", `Client ${message.client} disconnected.`);
306
+ break;
307
+
308
+ case "client_joined":
309
+ this.#participantCount = message.participantCount;
310
+ this.#triggerEvent("clientJoined", message.client);
311
+ this.#triggerEvent("status", `Client ${message.client} connected.`);
312
+ break;
313
+
314
+ case "request_succeeded":
315
+ this.#pendingRequests.get(message.request.uuid)?.resolve();
316
+ break;
317
+
318
+ case "request_failed":
319
+ this.#pendingRequests.get(message.request.uuid)?.reject(new Error(message.reason || "No reason provided."));
320
+ break;
321
+ }
322
+ } catch (error) {
323
+ console.error(ERROR_PREFIX + "Error handling message:", error);
324
+ }
325
+ };
326
+
327
+ // Handle socket errors
328
+ this.#socket.onerror = () => {
329
+ this.#triggerEvent("error", "WebSocket error.");
330
+ if (this.#pendingConnect) this.#pendingConnect.reject(new Error("WebSocket error!"));
331
+ }
332
+
333
+ // Handle socket close & attempt reconnect
334
+ this.#socket.onclose = () => {
335
+ if (!this.#initialized || this.#isReconnecting) return;
336
+ this.#triggerEvent("status", "Disconnected.");
337
+ this.#reconnectCount = 0;
338
+ this.#attemptReconnect();
339
+ }
340
+ };
341
+
342
+ /**
343
+ * Attempt to reconnect with a fixed number of retries
344
+ */
345
+ async #attemptReconnect() {
346
+ this.#reconnectCount++;
347
+ if (this.#reconnectCount > 9) {
348
+ this.#triggerEvent("error", "Disconnected from server.");
349
+ this.destroy();
350
+ return;
351
+ }
352
+
353
+ this.#triggerEvent("status", `Attempting to reconnect... (${this.#reconnectCount})`);
354
+ this.#isReconnecting = true;
355
+ try {
356
+ await this.#connect();
357
+ await Promise.race([
358
+ new Promise((resolve, reject) => {
359
+ this.#pendingReconnect = { resolve, reject };
360
+ this.#sendToServer({
361
+ type: "reconnect",
362
+ id: this.#id,
363
+ sessionToken: this.#sessionToken
364
+ });
365
+ }),
366
+ this.#createTimeout("Reconnection request")
367
+ ]).finally(() => {
368
+ this.#pendingReconnect = null;
369
+ });
370
+ } catch (error) {
371
+ if (!this.#initialized) return;
372
+ this.#triggerEvent("status", "Reconnection failed: " + error.message);
373
+ this.#reconnectTimeout = setTimeout(() => this.#attemptReconnect(), 500);
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Update the host in case a new one was chosen
379
+ * @param {string} hostId - Client ID of new host
380
+ */
381
+ #setHost(hostId) {
382
+ if (this.#roomHost != hostId) {
383
+ this.#roomHost = hostId;
384
+ this.#triggerEvent("hostMigrated", hostId);
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Send a message to the server
390
+ * @param {object} data - The data to send to the server
391
+ */
392
+ #sendToServer(data) {
393
+ if (!this.#socket || this.#socket?.readyState !== WebSocket.OPEN) {
394
+ console.warn(WARNING_PREFIX + "Failed to send message - not connected.");
395
+ return;
396
+ }
397
+ try {
398
+ this.#socket.send(encode(data));
399
+ } catch (error) {
400
+ console.error(ERROR_PREFIX + "Error sending message:", error);
401
+ this.#triggerEvent("error", "Error sending message: " + error.message);
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Create a new room and become host
407
+ * @param {object} [initialStorage] - Initial state
408
+ * @param {number} [size] - Max number of participants
409
+ * @returns {Promise<string>} Resolves with room ID
410
+ */
411
+ async createRoom(initialStorage = {}, size) {
412
+ if (!this.#initialized) {
413
+ this.#triggerEvent("error", "Failed to create room - not initialized");
414
+ throw new Error("Not initialized!");
415
+ }
416
+
417
+ return /** @type {Promise<string>} */ (Promise.race([
418
+ new Promise((resolve, reject) => {
419
+ this.#roomHost = this.#id;
420
+ this.#pendingCreate = { resolve, reject };
421
+ this.#sendToServer({
422
+ type: "create_room",
423
+ initialStorage,
424
+ size
425
+ });
426
+ }),
427
+ this.#createTimeout("Room creation")
428
+ ]).finally(() => {
429
+ this.#pendingCreate = null;
430
+ }));
431
+ }
432
+
433
+ /**
434
+ * Join an existing room
435
+ * @param {string} roomId - ID of the room
436
+ * @returns {Promise<void>} - Resolves when connected
437
+ */
438
+ async joinRoom(roomId) {
439
+ if (!this.#initialized) {
440
+ this.#triggerEvent("error", "Failed to join room - not initialized");
441
+ throw new Error("Not initialized!");
442
+ }
443
+
444
+ return /** @type {Promise<void>} */ (Promise.race([
445
+ new Promise((resolve, reject) => {
446
+ this.#pendingJoin = { resolve, reject };
447
+
448
+ // Send connection request
449
+ this.#triggerEvent("status", `Connecting to room ${roomId}...`);
450
+ this.#sendToServer({
451
+ type: "join_room",
452
+ roomId
453
+ });
454
+ }),
455
+ this.#createTimeout("Room join")
456
+ ]).finally(() => {
457
+ this.#pendingJoin = null;
458
+ }));
459
+ }
460
+
461
+ /**
462
+ * Update a value in the shared storage
463
+ * @param {string} key - Storage key
464
+ * @param {PropertyUpdateType} type - Operation type
465
+ * @param {*} value - Value
466
+ * @param {*} [secondValue] - Second value (needed for some operations)
467
+ */
468
+ updateStorage(key, type, value, secondValue) {
469
+ if (!this.#inRoom) {
470
+ this.#triggerEvent("error", "Failed to update storage - not in a room.");
471
+ return;
472
+ };
473
+ if (this.#debug) console.log(LOG_PREFIX + `Property update for key ${key}, operation ${type}, value ${value} and secondValue ${secondValue}.`);
474
+ const propUpdate = this.#crdtManager.updateProperty(key, type, value, secondValue);
475
+ this.#sendToServer({
476
+ type: "update_property",
477
+ update: propUpdate
478
+ });
479
+ if (this.#crdtManager.didPropertiesChange) this.#triggerEvent("storageUpdated", this.storage); // Always trigger callback AFTER send in case msgs are sent in the callback (which would break the order)
480
+ }
481
+
482
+ /**
483
+ * Send a custom request to the server
484
+ * @param {string} name - Name of the request
485
+ * @param {*} [data] - Custom data
486
+ * @returns {Promise<void>} - Request promise
487
+ */
488
+ async sendRequest(name, data) {
489
+ if (!this.#initialized) {
490
+ this.#triggerEvent("error", "Failed to send request - not initialized");
491
+ throw new Error("Not initialized!");
492
+ }
493
+ if (this.#debug) console.log(LOG_PREFIX + `Server request with name ${name} and data:`, data);
494
+ const uuid = crypto.randomUUID();
495
+ return /** @type {Promise<void>} */ (Promise.race([
496
+ new Promise((resolve, reject) => {
497
+ this.#pendingRequests.set(uuid, { resolve, reject });
498
+
499
+ this.#sendToServer({
500
+ type: "request",
501
+ request: { name, data, uuid }
502
+ });
503
+ }),
504
+ this.#createTimeout("Request")
505
+ ]).finally(() => {
506
+ this.#pendingRequests.delete(uuid);
507
+ }));
508
+ }
509
+
510
+ /**
511
+ * Destroy the PlaySocket instance and disconnect
512
+ */
513
+ destroy() {
514
+ this.#initialized = false; // Set this immediately to prevent automatic reconnection
515
+
516
+ if (this.#socket) {
517
+ // Signal to the server that it can immediately remove this user
518
+ if (this.#socket.readyState === WebSocket.OPEN) this.#sendToServer({ type: "disconnect" });
519
+ this.#socket.close();
520
+ this.#socket.onmessage = null;
521
+ this.#socket.onerror = null;
522
+ this.#socket.onclose = null;
523
+ this.#socket = null;
524
+
525
+ // Trigger events
526
+ this.#triggerEvent("status", "Destroyed.");
527
+ this.#triggerEvent("instanceDestroyed");
528
+ }
529
+
530
+ // Reset state
531
+ clearTimeout(this.#reconnectTimeout);
532
+ this.#roomHost = null;
533
+ this.#inRoom = false;
534
+ this.#participantCount = 0;
535
+ this.#isReconnecting = false;
536
+ this.#reconnectCount = 0;
537
+ this.#roomVersion = 0;
538
+
539
+ // Reject pending promises if currently active
540
+ if (this.#pendingJoin) this.#pendingJoin.reject(new Error("Destroyed."));
541
+ if (this.#pendingCreate) this.#pendingCreate.reject(new Error("Destroyed."));
542
+ if (this.#pendingRegistration) this.#pendingRegistration.reject(new Error("Destroyed."));
543
+ if (this.#pendingConnect) this.#pendingConnect.reject(new Error("Destroyed."));
544
+ if (this.#pendingReconnect) this.#pendingReconnect.reject(new Error("Destroyed."));
545
+ this.#pendingRequests.forEach((request) => {
546
+ request.reject(new Error("Destroyed."));
547
+ });
548
+ }
549
+
550
+ // Public getters
551
+ get participantCount() { return this.#participantCount; }
552
+ get storage() { return this.#crdtManager.propertyStore; }
553
+ get isHost() { return this.#id == this.#roomHost; }
554
+ get id() { return this.#id; }
555
+ }