playsocketjs 1.0.7 → 1.0.9
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/dist/playsocket-client.js +164 -82
- package/dist/playsocket-server.js +140 -32
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@ const TIMEOUT_MS = 5000; // 5 second timeout for operations
|
|
|
9
9
|
class PlaySocket {
|
|
10
10
|
// Core properties
|
|
11
11
|
#id; // Unique client ID
|
|
12
|
+
#sessionToken; // Unique session token
|
|
12
13
|
#endpoint;
|
|
13
14
|
#socket; // WebSocket connection
|
|
14
15
|
#initialized = false; // Initialization status
|
|
@@ -16,8 +17,7 @@ class PlaySocket {
|
|
|
16
17
|
|
|
17
18
|
// Room properties
|
|
18
19
|
#storage = {}; // Shared storage object
|
|
19
|
-
#storageTimestamps = {}; // Tracks update timestamps per key
|
|
20
|
-
#isHost = false; // Whether this client is host
|
|
20
|
+
#storageTimestamps = {}; // Tracks update timestamps per key to ensure that not-yet synced updates don't get overwritten
|
|
21
21
|
#roomHost;
|
|
22
22
|
#roomId; // ID of the host (client only)
|
|
23
23
|
#connectionCount = 0;
|
|
@@ -29,10 +29,14 @@ class PlaySocket {
|
|
|
29
29
|
// Async server operations
|
|
30
30
|
#pendingJoin;
|
|
31
31
|
#pendingHost;
|
|
32
|
-
#
|
|
32
|
+
#pendingRegistration;
|
|
33
|
+
#pendingConnect;
|
|
34
|
+
#pendingReconnect;
|
|
33
35
|
|
|
34
36
|
// Timeouts or intervals
|
|
35
37
|
#reconnectTimeout;
|
|
38
|
+
#reconnectCount = 0;
|
|
39
|
+
#isReconnecting = false;
|
|
36
40
|
|
|
37
41
|
/**
|
|
38
42
|
* Create a new PlaySocket instance
|
|
@@ -72,7 +76,7 @@ class PlaySocket {
|
|
|
72
76
|
*/
|
|
73
77
|
onEvent(event, callback) {
|
|
74
78
|
const validEvents = [
|
|
75
|
-
"status", "error", "instanceDestroyed", "storageUpdated", "hostMigrated", "clientConnected", "clientDisconnected"
|
|
79
|
+
"status", "error", "instanceDestroyed", "storageUpdated", "hostMigrated", "clientConnected", "clientDisconnected"
|
|
76
80
|
];
|
|
77
81
|
if (!validEvents.includes(event)) return console.warn(WARNING_PREFIX + `Invalid event type "${event}".`);
|
|
78
82
|
if (!this.#callbacks.has(event)) this.#callbacks.set(event, []);
|
|
@@ -99,40 +103,48 @@ class PlaySocket {
|
|
|
99
103
|
* @returns {Promise} Resolves when connection is established
|
|
100
104
|
*/
|
|
101
105
|
async init() {
|
|
102
|
-
if (this.#initialized) return Promise.reject(new Error("Already initialized
|
|
103
|
-
if (!this.#endpoint) return Promise.reject(new Error("No websocket endpoint provided
|
|
106
|
+
if (this.#initialized) return Promise.reject(new Error("Already initialized"));
|
|
107
|
+
if (!this.#endpoint) return Promise.reject(new Error("No websocket endpoint provided"));
|
|
104
108
|
this.#triggerEvent("status", "Initializing...");
|
|
105
109
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
this.#createTimeout("Initialization")
|
|
109
|
-
]);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Connect to the WS server and register
|
|
115
|
-
* @returns {Promise} Resolves when user is registered on the WS server
|
|
116
|
-
* @private
|
|
117
|
-
*/
|
|
118
|
-
async #connect() {
|
|
119
|
-
return new Promise((resolve, reject) => {
|
|
120
|
-
this.#socket = new WebSocket(this.#endpoint);
|
|
121
|
-
this.#setupSocketHandlers(); // Message & close events
|
|
110
|
+
// Connect to WS server
|
|
111
|
+
await this.#connect();
|
|
122
112
|
|
|
123
|
-
|
|
124
|
-
|
|
113
|
+
// Register with server
|
|
114
|
+
await Promise.race([
|
|
115
|
+
new Promise(async (resolve, reject) => {
|
|
116
|
+
this.#pendingRegistration = { resolve, reject }; // Resolve or reject depending on answer to registration msg
|
|
125
117
|
|
|
126
|
-
|
|
127
|
-
this.#socket.onopen = () => {
|
|
128
|
-
this.#initialized = true;
|
|
118
|
+
// Register with server
|
|
129
119
|
this.#sendToServer({
|
|
130
120
|
type: 'register',
|
|
131
121
|
id: this.#id,
|
|
132
122
|
customData: this.#customData || undefined,
|
|
133
123
|
clientTime: Date.now()
|
|
134
124
|
});
|
|
135
|
-
}
|
|
125
|
+
}),
|
|
126
|
+
this.#createTimeout("Registration")
|
|
127
|
+
]).finally(() => {
|
|
128
|
+
this.#pendingRegistration = null;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Connect to the WS server and
|
|
134
|
+
* @returns {Promise} Resolves when the connection was established
|
|
135
|
+
* @private
|
|
136
|
+
*/
|
|
137
|
+
async #connect() {
|
|
138
|
+
return Promise.race([
|
|
139
|
+
new Promise((resolve, reject) => {
|
|
140
|
+
this.#pendingConnect = { reject };
|
|
141
|
+
this.#socket = new WebSocket(this.#endpoint);
|
|
142
|
+
this.#setupSocketHandlers(); // Message & close events
|
|
143
|
+
this.#socket.onopen = resolve;
|
|
144
|
+
}),
|
|
145
|
+
this.#createTimeout("Connection attempt")
|
|
146
|
+
]).finally(() => {
|
|
147
|
+
this.#pendingConnect = null;
|
|
136
148
|
});
|
|
137
149
|
}
|
|
138
150
|
|
|
@@ -147,25 +159,53 @@ class PlaySocket {
|
|
|
147
159
|
if (!message.type) return;
|
|
148
160
|
|
|
149
161
|
switch (message.type) {
|
|
150
|
-
case '
|
|
162
|
+
case 'join_accepted':
|
|
151
163
|
// Connected to room
|
|
152
164
|
if (this.#pendingJoin) {
|
|
153
165
|
this.#storage = message.storage;
|
|
154
166
|
this.#storageTimestamps = message.storageTimestamps;
|
|
155
|
-
this.#connectionCount = message.participantCount - 1;
|
|
167
|
+
this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
|
|
168
|
+
this.#roomHost = message.host;
|
|
156
169
|
this.#triggerEvent("status", `Connected to room.`);
|
|
157
170
|
this.#triggerEvent("storageUpdated", { ...this.#storage });
|
|
158
171
|
this.#pendingJoin.resolve();
|
|
159
|
-
this.#pendingJoin = null;
|
|
160
172
|
}
|
|
161
173
|
break;
|
|
162
174
|
|
|
163
|
-
case '
|
|
175
|
+
case 'join_rejected':
|
|
164
176
|
// Connection to room failed
|
|
165
177
|
if (this.#pendingJoin) {
|
|
166
|
-
this.#triggerEvent("status", "Connection rejected: " + message.reason
|
|
167
|
-
this.#pendingJoin.reject(new Error("Connection rejected: " + message.reason
|
|
168
|
-
|
|
178
|
+
this.#triggerEvent("status", "Connection rejected: " + message.reason);
|
|
179
|
+
this.#pendingJoin.reject(new Error("Connection rejected: " + message.reason));
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
|
|
183
|
+
case 'reconnected':
|
|
184
|
+
// Successfully reconnected
|
|
185
|
+
if (this.#pendingReconnect) {
|
|
186
|
+
this.#isReconnecting = false;
|
|
187
|
+
this.#reconnectCount = 0;
|
|
188
|
+
if (message.roomData) {
|
|
189
|
+
this.#storage = message.roomData.storage;
|
|
190
|
+
this.#storageTimestamps = message.roomData.storageTimestamps;
|
|
191
|
+
this.#connectionCount = message.roomData.participantCount - 1; // Counted without the user themselves
|
|
192
|
+
this.#setHost(message.roomData.host);
|
|
193
|
+
this.#triggerEvent("storageUpdated", { ...this.#storage });
|
|
194
|
+
} else {
|
|
195
|
+
if (this.#roomId) {
|
|
196
|
+
this.#triggerEvent("error", "Reconnected, but room no longer exists.");
|
|
197
|
+
return this.destroy();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
this.#triggerEvent("status", "Reconnected.");
|
|
201
|
+
this.#pendingReconnect.resolve();
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
|
|
205
|
+
case 'reconnection_failed':
|
|
206
|
+
if (this.#pendingReconnect) {
|
|
207
|
+
this.#triggerEvent("status", `Reconnection failed: ${message.reason}`);
|
|
208
|
+
this.#pendingReconnect.reject(new Error("Reconnection failed: " + message.reason));
|
|
169
209
|
}
|
|
170
210
|
break;
|
|
171
211
|
|
|
@@ -174,34 +214,32 @@ class PlaySocket {
|
|
|
174
214
|
this.#pendingHost.resolve(this.#roomId);
|
|
175
215
|
this.#triggerEvent("status", `Room created${this.#pendingHost.maxSize ? ` with max size ${this.#pendingHost.maxSize}.` : '.'}`);
|
|
176
216
|
this.#triggerEvent("storageUpdated", { ...this.#storage });
|
|
177
|
-
this.#pendingHost = null;
|
|
178
217
|
}
|
|
179
218
|
break;
|
|
180
219
|
|
|
181
220
|
case 'room_creation_failed':
|
|
182
221
|
if (this.#pendingHost) {
|
|
183
|
-
this.#pendingHost.reject(new Error("Failed to create room: " + message.reason
|
|
184
|
-
this.#triggerEvent("error", "Failed to create room: " + message.reason
|
|
185
|
-
this.#pendingHost = null;
|
|
222
|
+
this.#pendingHost.reject(new Error("Failed to create room: " + message.reason));
|
|
223
|
+
this.#triggerEvent("error", "Failed to create room: " + message.reason);
|
|
186
224
|
}
|
|
187
225
|
break;
|
|
188
226
|
|
|
189
227
|
case 'id_taken':
|
|
190
|
-
if (this.#
|
|
191
|
-
this.#
|
|
228
|
+
if (this.#pendingRegistration) {
|
|
229
|
+
this.#pendingRegistration.reject(new Error("This id is already in use"));
|
|
192
230
|
this.#triggerEvent("error", "This id is taken.");
|
|
193
|
-
this.#pendingInit = null;
|
|
194
231
|
}
|
|
195
232
|
break;
|
|
196
233
|
|
|
197
234
|
case 'registered':
|
|
198
|
-
if (this.#
|
|
235
|
+
if (this.#pendingRegistration) {
|
|
199
236
|
const roundTripTime = Date.now() - message.clientTime;
|
|
200
237
|
const serverTimeAtClientNow = message.serverTime + (roundTripTime / 2);
|
|
201
238
|
this.#serverTimeOffset = serverTimeAtClientNow - Date.now();
|
|
202
|
-
this.#
|
|
239
|
+
this.#sessionToken = message.sessionToken;
|
|
240
|
+
this.#initialized = true;
|
|
241
|
+
this.#pendingRegistration.resolve();
|
|
203
242
|
this.#triggerEvent("status", "Connected to server.");
|
|
204
|
-
this.#pendingInit = null;
|
|
205
243
|
}
|
|
206
244
|
break;
|
|
207
245
|
|
|
@@ -216,22 +254,27 @@ class PlaySocket {
|
|
|
216
254
|
}
|
|
217
255
|
break;
|
|
218
256
|
|
|
257
|
+
case 'storage_operation':
|
|
258
|
+
if (message.key) {
|
|
259
|
+
const updatedArray = this.#handleArrayUpdate(message.key, message.operation, message.value, message.updateValue);
|
|
260
|
+
if (JSON.stringify(this.#storage[message.key]) !== JSON.stringify(updatedArray)) {
|
|
261
|
+
this.#storage[message.key] = updatedArray;
|
|
262
|
+
this.#storageTimestamps[message.key] = message.timestamp;
|
|
263
|
+
this.#triggerEvent("storageUpdated", { ...this.#storage });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
break;
|
|
267
|
+
|
|
219
268
|
case 'client_disconnected':
|
|
220
|
-
this.#isHost = this.#id == message.updatedHost;
|
|
221
269
|
this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
|
|
222
|
-
|
|
223
|
-
// If host has changed...
|
|
224
|
-
if (this.#roomHost != message.updatedHost) {
|
|
225
|
-
this.#roomHost = message.updatedHost;
|
|
226
|
-
this.#triggerEvent("hostMigrated", this.#roomHost);
|
|
227
|
-
}
|
|
270
|
+
this.#setHost(message.host);
|
|
228
271
|
|
|
229
272
|
this.#triggerEvent("clientDisconnected", message.client);
|
|
230
273
|
this.#triggerEvent("status", `Client ${message.client} disconnected.`);
|
|
231
274
|
break;
|
|
232
275
|
|
|
233
276
|
case 'client_connected':
|
|
234
|
-
this.#connectionCount = message.participantCount - 1;
|
|
277
|
+
this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
|
|
235
278
|
this.#triggerEvent("clientConnected", message.client);
|
|
236
279
|
this.#triggerEvent("status", `Client ${message.client} connected.`);
|
|
237
280
|
break;
|
|
@@ -244,37 +287,69 @@ class PlaySocket {
|
|
|
244
287
|
// Handle socket errors
|
|
245
288
|
this.#socket.onerror = () => {
|
|
246
289
|
this.#triggerEvent("error", "WebSocket error.");
|
|
247
|
-
if (this.#
|
|
290
|
+
if (this.#pendingConnect) this.#pendingConnect.reject(new Error("WebSocket error"));
|
|
248
291
|
};
|
|
249
292
|
|
|
250
293
|
// Handle socket close & attempt reconnect
|
|
251
294
|
this.#socket.onclose = () => {
|
|
252
|
-
if (!this.#initialized) return;
|
|
253
|
-
this.#triggerEvent("status", "Disconnected
|
|
254
|
-
|
|
255
|
-
this.#
|
|
256
|
-
try {
|
|
257
|
-
await this.#connect(); // Will set status to connected if successful
|
|
258
|
-
|
|
259
|
-
// Rejoin room if needed
|
|
260
|
-
if (this.#roomId) {
|
|
261
|
-
this.#triggerEvent("status", "Rejoining room...");
|
|
262
|
-
await this.joinRoom(this.#roomId); // If this fails, it will be caught by the outer catch
|
|
263
|
-
}
|
|
264
|
-
} catch (error) {
|
|
265
|
-
this.#triggerEvent("error", "WebSocket connection permanently closed: " + error);
|
|
266
|
-
this.destroy();
|
|
267
|
-
}
|
|
268
|
-
}, 1000);
|
|
295
|
+
if (!this.#initialized || this.#isReconnecting) return;
|
|
296
|
+
this.#triggerEvent("status", "Disconnected.");
|
|
297
|
+
this.#reconnectCount = 0;
|
|
298
|
+
this.#attemptReconnect();
|
|
269
299
|
};
|
|
270
300
|
};
|
|
271
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Attempt to reconnect with a fixed number of retries
|
|
304
|
+
* @private
|
|
305
|
+
*/
|
|
306
|
+
async #attemptReconnect() {
|
|
307
|
+
if (this.#reconnectCount++ >= 3) {
|
|
308
|
+
this.#triggerEvent("error", "Disconnected from server.");
|
|
309
|
+
return this.destroy();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.#triggerEvent("status", `Attempting to reconnect... (${this.#reconnectCount})`);
|
|
313
|
+
this.#isReconnecting = true;
|
|
314
|
+
try {
|
|
315
|
+
await this.#connect();
|
|
316
|
+
await Promise.race([
|
|
317
|
+
new Promise(async (resolve, reject) => {
|
|
318
|
+
this.#pendingReconnect = { resolve, reject };
|
|
319
|
+
this.#sendToServer({
|
|
320
|
+
type: 'reconnect',
|
|
321
|
+
id: this.#id,
|
|
322
|
+
sessionToken: this.#sessionToken
|
|
323
|
+
});
|
|
324
|
+
}),
|
|
325
|
+
this.#createTimeout("Reconnection request")
|
|
326
|
+
]).finally(() => {
|
|
327
|
+
this.#pendingReconnect = null;
|
|
328
|
+
});
|
|
329
|
+
} catch (error) {
|
|
330
|
+
this.#triggerEvent("status", "Reconnection failed: " + error.message);
|
|
331
|
+
this.#reconnectTimeout = setTimeout(() => this.#attemptReconnect(), 500);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Update the host in case a new one was chosen
|
|
337
|
+
* @param {string} hostId - Client id of new host
|
|
338
|
+
* @private
|
|
339
|
+
*/
|
|
340
|
+
#setHost(hostId) {
|
|
341
|
+
if (this.#roomHost != hostId) {
|
|
342
|
+
this.#roomHost = hostId;
|
|
343
|
+
this.#triggerEvent("hostMigrated", hostId);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
272
347
|
/**
|
|
273
348
|
* Send a message to the server
|
|
274
349
|
* @private
|
|
275
350
|
*/
|
|
276
351
|
#sendToServer(data) {
|
|
277
|
-
if (!this.#
|
|
352
|
+
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
|
|
278
353
|
return console.error(ERROR_PREFIX + "Cannot send message - not connected.");
|
|
279
354
|
}
|
|
280
355
|
try {
|
|
@@ -299,7 +374,6 @@ class PlaySocket {
|
|
|
299
374
|
|
|
300
375
|
return Promise.race([
|
|
301
376
|
new Promise((resolve, reject) => {
|
|
302
|
-
this.#isHost = true;
|
|
303
377
|
this.#storage = initialStorage;
|
|
304
378
|
this.#roomId = this.#id; // Create room with your own ID as the room ID to mimic p2p
|
|
305
379
|
this.#roomHost = this.#id;
|
|
@@ -313,7 +387,9 @@ class PlaySocket {
|
|
|
313
387
|
});
|
|
314
388
|
}),
|
|
315
389
|
this.#createTimeout("Room creation")
|
|
316
|
-
])
|
|
390
|
+
]).finally(() => {
|
|
391
|
+
this.#pendingHost = null;
|
|
392
|
+
});
|
|
317
393
|
}
|
|
318
394
|
|
|
319
395
|
/**
|
|
@@ -329,9 +405,7 @@ class PlaySocket {
|
|
|
329
405
|
|
|
330
406
|
return Promise.race([
|
|
331
407
|
new Promise((resolve, reject) => {
|
|
332
|
-
this.#isHost = false;
|
|
333
408
|
this.#roomId = roomId;
|
|
334
|
-
this.#roomHost = roomId;
|
|
335
409
|
this.#pendingJoin = { resolve, reject };
|
|
336
410
|
|
|
337
411
|
// Send connection request
|
|
@@ -342,7 +416,9 @@ class PlaySocket {
|
|
|
342
416
|
});
|
|
343
417
|
}),
|
|
344
418
|
this.#createTimeout("Room join")
|
|
345
|
-
])
|
|
419
|
+
]).finally(() => {
|
|
420
|
+
this.#pendingJoin = null;
|
|
421
|
+
});
|
|
346
422
|
}
|
|
347
423
|
|
|
348
424
|
/**
|
|
@@ -374,15 +450,17 @@ class PlaySocket {
|
|
|
374
450
|
updateStorageArray(key, operation, value, updateValue) {
|
|
375
451
|
const updatedArray = this.#handleArrayUpdate(key, operation, value, updateValue);
|
|
376
452
|
if (JSON.stringify(this.#storage[key]) === JSON.stringify(updatedArray)) return; // Prevent updates without changes
|
|
453
|
+
const timestamp = this.#getTimestamp();
|
|
377
454
|
this.#sendToServer({
|
|
378
455
|
type: 'room_storage_array_update',
|
|
379
456
|
key,
|
|
380
457
|
operation,
|
|
381
458
|
value,
|
|
382
|
-
updateValue
|
|
459
|
+
updateValue,
|
|
460
|
+
timestamp
|
|
383
461
|
});
|
|
384
462
|
this.#storage[key] = updatedArray;
|
|
385
|
-
this.#storageTimestamps[key] =
|
|
463
|
+
this.#storageTimestamps[key] = timestamp;
|
|
386
464
|
this.#triggerEvent("storageUpdated", { ...this.#storage });
|
|
387
465
|
}
|
|
388
466
|
|
|
@@ -427,6 +505,8 @@ class PlaySocket {
|
|
|
427
505
|
this.#initialized = false; // Set this immediately to prevent automatic reconnection
|
|
428
506
|
|
|
429
507
|
if (this.#socket) {
|
|
508
|
+
// Signal to the server that it can immediately disconnect this user
|
|
509
|
+
if (this.#socket.readyState === WebSocket.OPEN) this.#sendToServer({ type: 'disconnect' });
|
|
430
510
|
this.#socket.close();
|
|
431
511
|
this.#socket = null;
|
|
432
512
|
|
|
@@ -436,19 +516,21 @@ class PlaySocket {
|
|
|
436
516
|
}
|
|
437
517
|
|
|
438
518
|
// Reset state
|
|
439
|
-
this.#
|
|
519
|
+
clearTimeout(this.#reconnectTimeout);
|
|
440
520
|
this.#roomHost = null;
|
|
441
521
|
this.#storage = {};
|
|
442
522
|
this.#storageTimestamps = {};
|
|
523
|
+
this.#serverTimeOffset = 0;
|
|
443
524
|
this.#roomId = null;
|
|
444
525
|
this.#connectionCount = 0;
|
|
445
|
-
|
|
526
|
+
this.#isReconnecting = false;
|
|
527
|
+
this.#reconnectCount = 0;
|
|
446
528
|
}
|
|
447
529
|
|
|
448
530
|
// Public getters
|
|
449
531
|
get connectionCount() { return this.#connectionCount; }
|
|
450
532
|
get getStorage() { return { ...this.#storage }; }
|
|
451
|
-
get isHost() { return this.#
|
|
533
|
+
get isHost() { return this.#id == this.#roomHost; }
|
|
452
534
|
get id() { return this.#id; }
|
|
453
535
|
}
|
|
454
536
|
|
|
@@ -27,8 +27,10 @@ function requireServer () {
|
|
|
27
27
|
#rooms = {};
|
|
28
28
|
#clientRooms = new Map(); // ClientId -> RoomId
|
|
29
29
|
#rateLimits = new Map(); // Rate limiting storage
|
|
30
|
-
#callbacks = new Map();
|
|
30
|
+
#callbacks = new Map(); // Event -> [callback functions]
|
|
31
31
|
#heartbeatInterval;
|
|
32
|
+
#pendingDisconnects = new Map(); // ClientId -> {timeout, roomId}
|
|
33
|
+
#clientTokens = new Map(); // ClientId -> Token
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
36
|
* Create a new PlaySocketServer instance
|
|
@@ -98,10 +100,45 @@ function requireServer () {
|
|
|
98
100
|
}
|
|
99
101
|
ws.clientId = data.id; // Adds the provided id to the ws object as clientId
|
|
100
102
|
this.#clients.set(data.id, ws);
|
|
101
|
-
|
|
103
|
+
const sessionToken = this.#generateSessionToken();
|
|
104
|
+
this.#clientTokens.set(data.id, sessionToken); // Token is used when reconnecting to prevent impersonation attacks
|
|
105
|
+
ws.send(JSON.stringify({ type: 'registered', sessionToken, serverTime: Date.now(), clientTime: data.clientTime }));
|
|
102
106
|
this.#triggerEvent("clientRegistered", data.id, data.customData);
|
|
103
107
|
break;
|
|
104
108
|
|
|
109
|
+
case 'reconnect':
|
|
110
|
+
// If user is pending disconnect, respond (otherwise it's too late)
|
|
111
|
+
const pd = this.#pendingDisconnects.get(data.id);
|
|
112
|
+
if (pd && data.sessionToken) {
|
|
113
|
+
if (data.sessionToken !== this.#clientTokens.get(data.id)) {
|
|
114
|
+
ws.send(JSON.stringify({ type: 'reconnection_failed', reason: "Session token does not match" }));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
clearTimeout(pd.timeout);
|
|
118
|
+
this.#pendingDisconnects.delete(data.id);
|
|
119
|
+
|
|
120
|
+
// Re-assign old client id to the new ws connection
|
|
121
|
+
ws.clientId = data.id;
|
|
122
|
+
this.#clients.set(data.id, ws);
|
|
123
|
+
|
|
124
|
+
// If they were in a room, provide updated room data
|
|
125
|
+
let roomData;
|
|
126
|
+
const formerRoom = this.#rooms[this.#clientRooms.get(data.id)];
|
|
127
|
+
if (formerRoom) {
|
|
128
|
+
roomData = {
|
|
129
|
+
storage: formerRoom.storage,
|
|
130
|
+
storageTimestamps: formerRoom.storageTimestamps,
|
|
131
|
+
participantCount: formerRoom.participants.length,
|
|
132
|
+
host: formerRoom.participants[0]
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
ws.send(JSON.stringify({ type: 'reconnected', roomData }));
|
|
137
|
+
} else {
|
|
138
|
+
ws.send(JSON.stringify({ type: 'reconnection_failed', reason: "Client unknown to server" }));
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
|
|
105
142
|
case 'create_room':
|
|
106
143
|
if (!ws.clientId) return;
|
|
107
144
|
const newRoomId = ws.clientId;
|
|
@@ -126,7 +163,7 @@ function requireServer () {
|
|
|
126
163
|
break;
|
|
127
164
|
|
|
128
165
|
case 'join_room':
|
|
129
|
-
if (!data.roomId) return;
|
|
166
|
+
if (!data.roomId || !ws.clientId) return;
|
|
130
167
|
const roomId = data.roomId;
|
|
131
168
|
const room = this.#rooms[roomId];
|
|
132
169
|
|
|
@@ -134,7 +171,7 @@ function requireServer () {
|
|
|
134
171
|
(room.maxSize && room.participants.length >= room.maxSize) ||
|
|
135
172
|
this.#clientRooms.get(ws.clientId)) {
|
|
136
173
|
ws.send(JSON.stringify({
|
|
137
|
-
type: '
|
|
174
|
+
type: 'join_rejected',
|
|
138
175
|
reason: !room ? 'Room not found' :
|
|
139
176
|
room.maxSize && room.participants.length >= room.maxSize ? 'Room full' :
|
|
140
177
|
'Already in a room'
|
|
@@ -147,10 +184,11 @@ function requireServer () {
|
|
|
147
184
|
|
|
148
185
|
// Notify joiner with initial storage
|
|
149
186
|
ws.send(JSON.stringify({
|
|
150
|
-
type: '
|
|
187
|
+
type: 'join_accepted',
|
|
151
188
|
storage: room.storage,
|
|
152
189
|
storageTimestamps: room.storageTimestamps,
|
|
153
|
-
participantCount: room.participants.length
|
|
190
|
+
participantCount: room.participants.length,
|
|
191
|
+
host: room.participants[0]
|
|
154
192
|
}));
|
|
155
193
|
|
|
156
194
|
// Notify existing participants
|
|
@@ -176,7 +214,7 @@ function requireServer () {
|
|
|
176
214
|
if (data.timestamp > existingTimestamp && JSON.stringify(updateRoom.storage[data.key]) !== JSON.stringify(data.value)) {
|
|
177
215
|
updateRoom.storage[data.key] = data.value;
|
|
178
216
|
updateRoom.storageTimestamps[data.key] = data.timestamp;
|
|
179
|
-
this.#syncRoomStorageKey(updateRoomId, data.key, data.timestamp);
|
|
217
|
+
this.#syncRoomStorageKey(updateRoomId, ws.clientId, data.key, data.timestamp);
|
|
180
218
|
}
|
|
181
219
|
}
|
|
182
220
|
break;
|
|
@@ -185,20 +223,36 @@ function requireServer () {
|
|
|
185
223
|
const arrayRoomId = this.#clientRooms.get(ws.clientId);
|
|
186
224
|
const arrayRoom = arrayRoomId ? this.#rooms[arrayRoomId] : null;
|
|
187
225
|
if (arrayRoom && data.key) {
|
|
226
|
+
// No timestamp check for array operations as they are designed to work unordered
|
|
188
227
|
const updatedArray = this.#arrayUpdate(arrayRoom.storage, data.key, data.operation, data.value, data.updateValue);
|
|
189
228
|
if (JSON.stringify(arrayRoom.storage[data.key]) !== JSON.stringify(updatedArray)) {
|
|
190
229
|
arrayRoom.storage[data.key] = updatedArray;
|
|
191
|
-
arrayRoom.storageTimestamps[data.key] =
|
|
192
|
-
this.#
|
|
230
|
+
arrayRoom.storageTimestamps[data.key] = data.timestamp;
|
|
231
|
+
this.#syncRoomStorageOperation(arrayRoomId, ws.clientId, data.key, data.operation, data.value, data.updateValue, data.timestamp);
|
|
193
232
|
}
|
|
194
233
|
}
|
|
195
234
|
break;
|
|
235
|
+
|
|
236
|
+
case 'disconnect':
|
|
237
|
+
// Client signals to server that it will will willfully disconnect soon
|
|
238
|
+
ws.willfulDisconnect = true;
|
|
239
|
+
break;
|
|
196
240
|
}
|
|
197
241
|
} catch (error) {
|
|
198
242
|
console.error('Error processing message:', error);
|
|
199
243
|
}
|
|
200
244
|
}
|
|
201
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Generate a random token to prevent malicious reconnect attempts
|
|
248
|
+
* @returns {string} - Token
|
|
249
|
+
*/
|
|
250
|
+
#generateSessionToken() {
|
|
251
|
+
let token = '';
|
|
252
|
+
for (let i = 0; i < 16; i++) token += Math.floor(Math.random() * 16).toString(16);
|
|
253
|
+
return token;
|
|
254
|
+
}
|
|
255
|
+
|
|
202
256
|
/**
|
|
203
257
|
* Check rate limit using token bucket algorithm
|
|
204
258
|
* @private
|
|
@@ -228,13 +282,18 @@ function requireServer () {
|
|
|
228
282
|
}
|
|
229
283
|
|
|
230
284
|
/**
|
|
231
|
-
* Sync storage key
|
|
285
|
+
* Sync storage key with all clients in room
|
|
232
286
|
* @private
|
|
287
|
+
* @param {string} roomId
|
|
288
|
+
* @param {string} requesterId - Client id of whom initiated the operation
|
|
289
|
+
* @param {string} key
|
|
290
|
+
* @param {number} timestamp - In milliseconds
|
|
233
291
|
*/
|
|
234
|
-
#syncRoomStorageKey(roomId, key, timestamp) {
|
|
292
|
+
#syncRoomStorageKey(roomId, requesterId, key, timestamp) {
|
|
235
293
|
const room = this.#rooms[roomId];
|
|
236
294
|
if (!room) return;
|
|
237
295
|
room.participants.forEach(p => {
|
|
296
|
+
if (p == requesterId) return; // Don't sync with the requester
|
|
238
297
|
const client = this.#clients.get(p);
|
|
239
298
|
if (client) {
|
|
240
299
|
client.send(JSON.stringify({
|
|
@@ -247,6 +306,36 @@ function requireServer () {
|
|
|
247
306
|
});
|
|
248
307
|
}
|
|
249
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Sync storage operations with all clients in room
|
|
311
|
+
* @private
|
|
312
|
+
* @param {string} roomId
|
|
313
|
+
* @param {string} requesterId - Client id of whom initiated the operation
|
|
314
|
+
* @param {string} key
|
|
315
|
+
* @param {string} operation
|
|
316
|
+
* @param {*} value
|
|
317
|
+
* @param {*} updateValue
|
|
318
|
+
* @param {number} timestamp - In milliseconds
|
|
319
|
+
*/
|
|
320
|
+
#syncRoomStorageOperation(roomId, requesterId, key, operation, value, updateValue, timestamp) {
|
|
321
|
+
const room = this.#rooms[roomId];
|
|
322
|
+
if (!room) return;
|
|
323
|
+
room.participants.forEach(p => {
|
|
324
|
+
if (p == requesterId) return; // Don't send the operation to the requester
|
|
325
|
+
const client = this.#clients.get(p);
|
|
326
|
+
if (client) {
|
|
327
|
+
client.send(JSON.stringify({
|
|
328
|
+
type: 'storage_operation',
|
|
329
|
+
key,
|
|
330
|
+
operation,
|
|
331
|
+
value,
|
|
332
|
+
updateValue,
|
|
333
|
+
timestamp
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
250
339
|
/**
|
|
251
340
|
* Handle array operations
|
|
252
341
|
* @private
|
|
@@ -284,34 +373,50 @@ function requireServer () {
|
|
|
284
373
|
*/
|
|
285
374
|
#handleDisconnection(ws) {
|
|
286
375
|
if (!ws.clientId) return;
|
|
376
|
+
this.#clients.delete(ws.clientId); // Immediately remove from active clients (otherwise, server would try to message this client)
|
|
287
377
|
|
|
288
|
-
|
|
289
|
-
|
|
378
|
+
if (ws.willfulDisconnect) {
|
|
379
|
+
this.#disconnectClient(ws); // Immediate disconnection
|
|
380
|
+
} else {
|
|
381
|
+
// Pending complete disconnection with 2s grace period to allow for reconnections
|
|
382
|
+
this.#pendingDisconnects.set(ws.clientId, {
|
|
383
|
+
timeout: setTimeout(() => {
|
|
384
|
+
this.#disconnectClient(ws);
|
|
385
|
+
}, 2000)
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
290
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Disconnect a client
|
|
392
|
+
* @private
|
|
393
|
+
* @param {Object} ws
|
|
394
|
+
*/
|
|
395
|
+
#disconnectClient(ws) {
|
|
396
|
+
this.#pendingDisconnects.delete(ws.clientId);
|
|
397
|
+
this.#rateLimits.delete(ws.clientId);
|
|
398
|
+
this.#clientTokens.delete(ws.clientId);
|
|
291
399
|
const roomId = this.#clientRooms.get(ws.clientId);
|
|
292
|
-
const room =
|
|
400
|
+
const room = this.#rooms[roomId];
|
|
293
401
|
|
|
294
402
|
if (room) {
|
|
295
|
-
room.participants = room.participants
|
|
403
|
+
room.participants = room.participants.filter(p => p !== ws.clientId); // Remove client from room
|
|
296
404
|
this.#clientRooms.delete(ws.clientId);
|
|
297
405
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
313
|
-
});
|
|
314
|
-
if (room.participants?.length === 0) delete this.#rooms[roomId]; // Delete room if now empty
|
|
406
|
+
if (room.participants?.length === 0) {
|
|
407
|
+
delete this.#rooms[roomId]; // Delete room if now empty
|
|
408
|
+
} else {
|
|
409
|
+
// Notify remaining participants
|
|
410
|
+
room.participants.forEach(p => {
|
|
411
|
+
const client = this.#clients.get(p);
|
|
412
|
+
if (client) client.send(JSON.stringify({
|
|
413
|
+
type: 'client_disconnected',
|
|
414
|
+
client: ws.clientId,
|
|
415
|
+
host: room.participants[0],
|
|
416
|
+
participantCount: room.participants.length
|
|
417
|
+
}));
|
|
418
|
+
});
|
|
419
|
+
}
|
|
315
420
|
}
|
|
316
421
|
|
|
317
422
|
this.#triggerEvent("clientDisconnected", ws.clientId);
|
|
@@ -354,6 +459,9 @@ function requireServer () {
|
|
|
354
459
|
console.log('PlaySocket server stopped');
|
|
355
460
|
});
|
|
356
461
|
}
|
|
462
|
+
this.#pendingDisconnects.forEach((data) => {
|
|
463
|
+
clearTimeout(data.timeout);
|
|
464
|
+
});
|
|
357
465
|
}
|
|
358
466
|
|
|
359
467
|
get getRooms() { return { ...this.#rooms } }
|