playsocketjs 3.1.2 → 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.
- package/README.md +243 -142
- package/package.json +24 -13
- package/src/client/client.js +555 -0
- package/src/server/server.js +761 -0
- package/src/server/ws-extensions.d.ts +13 -0
- package/src/universal/constants.js +7 -0
- package/src/universal/crdtManager.js +498 -0
- package/types/client/client.d.ts +97 -0
- package/types/server/server.d.ts +142 -0
- package/types/universal/constants.d.ts +7 -0
- package/types/universal/crdtManager.d.ts +146 -0
- package/dist/playsocket-client.js +0 -2614
- package/dist/playsocket-server.js +0 -2816
|
@@ -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
|
+
}
|