playsocketjs 1.0.6 → 1.0.8
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 +162 -74
- package/dist/playsocket-server.js +102 -40
- 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,10 +17,11 @@ class PlaySocket {
|
|
|
16
17
|
|
|
17
18
|
// Room properties
|
|
18
19
|
#storage = {}; // Shared storage object
|
|
19
|
-
#
|
|
20
|
+
#storageTimestamps = {}; // Tracks update timestamps per key
|
|
20
21
|
#roomHost;
|
|
21
22
|
#roomId; // ID of the host (client only)
|
|
22
23
|
#connectionCount = 0;
|
|
24
|
+
#serverTimeOffset = 0; // Offset between server time and local time
|
|
23
25
|
|
|
24
26
|
// Event handling
|
|
25
27
|
#callbacks = new Map(); // Event callbacks
|
|
@@ -27,10 +29,14 @@ class PlaySocket {
|
|
|
27
29
|
// Async server operations
|
|
28
30
|
#pendingJoin;
|
|
29
31
|
#pendingHost;
|
|
30
|
-
#
|
|
32
|
+
#pendingRegistration;
|
|
33
|
+
#pendingConnect;
|
|
34
|
+
#pendingReconnect;
|
|
31
35
|
|
|
32
36
|
// Timeouts or intervals
|
|
33
37
|
#reconnectTimeout;
|
|
38
|
+
#reconnectCount = 0;
|
|
39
|
+
#isReconnecting = false;
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
42
|
* Create a new PlaySocket instance
|
|
@@ -55,6 +61,14 @@ class PlaySocket {
|
|
|
55
61
|
);
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Get synchronized timestamp
|
|
66
|
+
* @private
|
|
67
|
+
*/
|
|
68
|
+
#getTimestamp() {
|
|
69
|
+
return Date.now() + this.#serverTimeOffset;
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
/**
|
|
59
73
|
* Register an event callback
|
|
60
74
|
* @param {string} event - Event name
|
|
@@ -62,7 +76,7 @@ class PlaySocket {
|
|
|
62
76
|
*/
|
|
63
77
|
onEvent(event, callback) {
|
|
64
78
|
const validEvents = [
|
|
65
|
-
"status", "error", "instanceDestroyed", "storageUpdated", "hostMigrated", "clientConnected", "clientDisconnected"
|
|
79
|
+
"status", "error", "instanceDestroyed", "storageUpdated", "hostMigrated", "clientConnected", "clientDisconnected"
|
|
66
80
|
];
|
|
67
81
|
if (!validEvents.includes(event)) return console.warn(WARNING_PREFIX + `Invalid event type "${event}".`);
|
|
68
82
|
if (!this.#callbacks.has(event)) this.#callbacks.set(event, []);
|
|
@@ -89,40 +103,45 @@ class PlaySocket {
|
|
|
89
103
|
* @returns {Promise} Resolves when connection is established
|
|
90
104
|
*/
|
|
91
105
|
async init() {
|
|
92
|
-
if (this.#initialized) return Promise.reject(new Error("Already initialized
|
|
93
|
-
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"));
|
|
94
108
|
this.#triggerEvent("status", "Initializing...");
|
|
95
109
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
// Connect to WS server
|
|
111
|
+
await this.#connect();
|
|
112
|
+
|
|
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
|
|
117
|
+
|
|
118
|
+
// Register with server
|
|
119
|
+
this.#sendToServer({
|
|
120
|
+
type: 'register',
|
|
121
|
+
id: this.#id,
|
|
122
|
+
customData: this.#customData || undefined,
|
|
123
|
+
clientTime: Date.now()
|
|
124
|
+
});
|
|
125
|
+
}),
|
|
126
|
+
this.#createTimeout("Registration")
|
|
99
127
|
]);
|
|
100
128
|
}
|
|
101
129
|
|
|
102
|
-
|
|
103
130
|
/**
|
|
104
|
-
* Connect to the WS server and
|
|
105
|
-
* @returns {Promise} Resolves when
|
|
131
|
+
* Connect to the WS server and
|
|
132
|
+
* @returns {Promise} Resolves when the connection was established
|
|
106
133
|
* @private
|
|
107
134
|
*/
|
|
108
135
|
async #connect() {
|
|
109
|
-
return
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
this.#initialized = true;
|
|
119
|
-
this.#sendToServer({
|
|
120
|
-
type: 'register',
|
|
121
|
-
id: this.#id,
|
|
122
|
-
customData: this.#customData || undefined
|
|
123
|
-
});
|
|
124
|
-
};
|
|
125
|
-
});
|
|
136
|
+
return Promise.race([
|
|
137
|
+
new Promise((resolve, reject) => {
|
|
138
|
+
this.#pendingConnect = { reject };
|
|
139
|
+
this.#socket = new WebSocket(this.#endpoint);
|
|
140
|
+
this.#setupSocketHandlers(); // Message & close events
|
|
141
|
+
this.#socket.onopen = resolve;
|
|
142
|
+
}),
|
|
143
|
+
this.#createTimeout("Connection attempt")
|
|
144
|
+
]);
|
|
126
145
|
}
|
|
127
146
|
|
|
128
147
|
/**
|
|
@@ -136,11 +155,13 @@ class PlaySocket {
|
|
|
136
155
|
if (!message.type) return;
|
|
137
156
|
|
|
138
157
|
switch (message.type) {
|
|
139
|
-
case '
|
|
158
|
+
case 'join_accepted':
|
|
140
159
|
// Connected to room
|
|
141
160
|
if (this.#pendingJoin) {
|
|
142
161
|
this.#storage = message.storage;
|
|
143
|
-
this.#
|
|
162
|
+
this.#storageTimestamps = message.storageTimestamps;
|
|
163
|
+
this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
|
|
164
|
+
this.#roomHost = message.host;
|
|
144
165
|
this.#triggerEvent("status", `Connected to room.`);
|
|
145
166
|
this.#triggerEvent("storageUpdated", { ...this.#storage });
|
|
146
167
|
this.#pendingJoin.resolve();
|
|
@@ -148,7 +169,7 @@ class PlaySocket {
|
|
|
148
169
|
}
|
|
149
170
|
break;
|
|
150
171
|
|
|
151
|
-
case '
|
|
172
|
+
case 'join_rejected':
|
|
152
173
|
// Connection to room failed
|
|
153
174
|
if (this.#pendingJoin) {
|
|
154
175
|
this.#triggerEvent("status", "Connection rejected: " + message.reason || 'Unknown reason');
|
|
@@ -157,6 +178,37 @@ class PlaySocket {
|
|
|
157
178
|
}
|
|
158
179
|
break;
|
|
159
180
|
|
|
181
|
+
case 'reconnected':
|
|
182
|
+
// Successfully reconnected
|
|
183
|
+
if (this.#pendingReconnect) {
|
|
184
|
+
this.#isReconnecting = false;
|
|
185
|
+
this.#reconnectCount = 0;
|
|
186
|
+
if (message.roomData) {
|
|
187
|
+
this.#storage = message.roomData.storage;
|
|
188
|
+
this.#storageTimestamps = message.roomData.storageTimestamps;
|
|
189
|
+
this.#connectionCount = message.roomData.participantCount - 1; // Counted without the user themselves
|
|
190
|
+
this.#setHost(message.roomData.host);
|
|
191
|
+
this.#triggerEvent("storageUpdated", { ...this.#storage });
|
|
192
|
+
} else {
|
|
193
|
+
if (this.#roomId) {
|
|
194
|
+
this.#triggerEvent("error", "Reconnected, but room no longer exists.");
|
|
195
|
+
return this.destroy();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
this.#triggerEvent("status", "Reconnected.");
|
|
199
|
+
this.#pendingReconnect.resolve();
|
|
200
|
+
this.#pendingReconnect = null;
|
|
201
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
|
|
204
|
+
case 'reconnection_failed':
|
|
205
|
+
if (this.#pendingReconnect) {
|
|
206
|
+
this.#triggerEvent("status", `Reconnection failed: ${message.reason}`);
|
|
207
|
+
this.#pendingReconnect.resolve();
|
|
208
|
+
this.#pendingReconnect = null;
|
|
209
|
+
}
|
|
210
|
+
break;
|
|
211
|
+
|
|
160
212
|
case 'room_created':
|
|
161
213
|
if (this.#pendingHost) {
|
|
162
214
|
this.#pendingHost.resolve(this.#roomId);
|
|
@@ -175,44 +227,47 @@ class PlaySocket {
|
|
|
175
227
|
break;
|
|
176
228
|
|
|
177
229
|
case 'id_taken':
|
|
178
|
-
if (this.#
|
|
179
|
-
this.#
|
|
230
|
+
if (this.#pendingRegistration) {
|
|
231
|
+
this.#pendingRegistration.reject(new Error("This id is already in use"));
|
|
232
|
+
this.#pendingRegistration = null;
|
|
180
233
|
this.#triggerEvent("error", "This id is taken.");
|
|
181
|
-
this.#pendingInit = null;
|
|
182
234
|
}
|
|
183
235
|
break;
|
|
184
236
|
|
|
185
237
|
case 'registered':
|
|
186
|
-
if (this.#
|
|
187
|
-
|
|
238
|
+
if (this.#pendingRegistration) {
|
|
239
|
+
const roundTripTime = Date.now() - message.clientTime;
|
|
240
|
+
const serverTimeAtClientNow = message.serverTime + (roundTripTime / 2);
|
|
241
|
+
this.#serverTimeOffset = serverTimeAtClientNow - Date.now();
|
|
242
|
+
this.#sessionToken = message.sessionToken;
|
|
243
|
+
this.#initialized = true;
|
|
244
|
+
this.#pendingRegistration.resolve();
|
|
245
|
+
this.#pendingRegistration = null;
|
|
188
246
|
this.#triggerEvent("status", "Connected to server.");
|
|
189
|
-
this.#pendingInit = null;
|
|
190
247
|
}
|
|
191
248
|
break;
|
|
192
249
|
|
|
193
250
|
case 'storage_sync':
|
|
194
|
-
if (message.key
|
|
195
|
-
this.#
|
|
196
|
-
|
|
251
|
+
if (message.key) {
|
|
252
|
+
const localTimestamp = this.#storageTimestamps[message.key] || 0;
|
|
253
|
+
if (message.timestamp > localTimestamp && JSON.stringify(this.#storage[message.key]) !== JSON.stringify(message.value)) {
|
|
254
|
+
this.#storage[message.key] = message.value;
|
|
255
|
+
this.#storageTimestamps[message.key] = message.timestamp;
|
|
256
|
+
this.#triggerEvent("storageUpdated", { ...this.#storage });
|
|
257
|
+
}
|
|
197
258
|
}
|
|
198
259
|
break;
|
|
199
260
|
|
|
200
261
|
case 'client_disconnected':
|
|
201
|
-
this.#isHost = this.#id == message.updatedHost;
|
|
202
262
|
this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
|
|
203
|
-
|
|
204
|
-
// If host has changed...
|
|
205
|
-
if (this.#roomHost != message.updatedHost) {
|
|
206
|
-
this.#roomHost = message.updatedHost;
|
|
207
|
-
this.#triggerEvent("hostMigrated", this.#roomHost);
|
|
208
|
-
}
|
|
263
|
+
this.#setHost(message.host);
|
|
209
264
|
|
|
210
265
|
this.#triggerEvent("clientDisconnected", message.client);
|
|
211
266
|
this.#triggerEvent("status", `Client ${message.client} disconnected.`);
|
|
212
267
|
break;
|
|
213
268
|
|
|
214
269
|
case 'client_connected':
|
|
215
|
-
this.#connectionCount = message.participantCount - 1;
|
|
270
|
+
this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
|
|
216
271
|
this.#triggerEvent("clientConnected", message.client);
|
|
217
272
|
this.#triggerEvent("status", `Client ${message.client} connected.`);
|
|
218
273
|
break;
|
|
@@ -225,37 +280,67 @@ class PlaySocket {
|
|
|
225
280
|
// Handle socket errors
|
|
226
281
|
this.#socket.onerror = () => {
|
|
227
282
|
this.#triggerEvent("error", "WebSocket error.");
|
|
228
|
-
if (this.#
|
|
283
|
+
if (this.#pendingConnect) this.#pendingConnect.reject(new Error("WebSocket error"));
|
|
229
284
|
};
|
|
230
285
|
|
|
231
286
|
// Handle socket close & attempt reconnect
|
|
232
287
|
this.#socket.onclose = () => {
|
|
233
|
-
if (!this.#initialized) return;
|
|
234
|
-
this.#triggerEvent("status", "Disconnected
|
|
235
|
-
|
|
236
|
-
this.#
|
|
237
|
-
try {
|
|
238
|
-
await this.#connect(); // Will set status to connected if successful
|
|
239
|
-
|
|
240
|
-
// Rejoin room if needed
|
|
241
|
-
if (this.#roomId) {
|
|
242
|
-
this.#triggerEvent("status", "Rejoining room...");
|
|
243
|
-
await this.joinRoom(this.#roomId); // If this fails, it will be caught by the outer catch
|
|
244
|
-
}
|
|
245
|
-
} catch (error) {
|
|
246
|
-
this.#triggerEvent("error", "WebSocket connection permanently closed: " + error);
|
|
247
|
-
this.destroy();
|
|
248
|
-
}
|
|
249
|
-
}, 1000);
|
|
288
|
+
if (!this.#initialized || this.#isReconnecting) return;
|
|
289
|
+
this.#triggerEvent("status", "Disconnected.");
|
|
290
|
+
this.#reconnectCount = 0;
|
|
291
|
+
this.#attemptReconnect();
|
|
250
292
|
};
|
|
251
293
|
};
|
|
252
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Attempt to reconnect with a fixed number of retries
|
|
297
|
+
* @private
|
|
298
|
+
*/
|
|
299
|
+
async #attemptReconnect() {
|
|
300
|
+
if (this.#reconnectCount++ >= 3) {
|
|
301
|
+
this.#triggerEvent("error", "Disconnected from server.");
|
|
302
|
+
return this.destroy();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
this.#triggerEvent("status", `Attempting to reconnect... (${this.#reconnectCount})`);
|
|
306
|
+
this.#isReconnecting = true;
|
|
307
|
+
try {
|
|
308
|
+
await this.#connect();
|
|
309
|
+
await Promise.race([
|
|
310
|
+
new Promise(async (resolve, reject) => {
|
|
311
|
+
this.#pendingReconnect = { resolve, reject };
|
|
312
|
+
this.#sendToServer({
|
|
313
|
+
type: 'reconnect',
|
|
314
|
+
id: this.#id,
|
|
315
|
+
sessionToken: this.#sessionToken
|
|
316
|
+
});
|
|
317
|
+
}),
|
|
318
|
+
this.#createTimeout("Reconnection request")
|
|
319
|
+
]);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
this.#triggerEvent("status", "Reconnection failed: " + error.message);
|
|
322
|
+
this.#reconnectTimeout = setTimeout(() => this.#attemptReconnect(), 500);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Update the host in case a new one was chosen
|
|
328
|
+
* @param {string} hostId - Client id of new host
|
|
329
|
+
* @private
|
|
330
|
+
*/
|
|
331
|
+
#setHost(hostId) {
|
|
332
|
+
if (this.#roomHost != hostId) {
|
|
333
|
+
this.#roomHost = hostId;
|
|
334
|
+
this.#triggerEvent("hostMigrated", hostId);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
253
338
|
/**
|
|
254
339
|
* Send a message to the server
|
|
255
340
|
* @private
|
|
256
341
|
*/
|
|
257
342
|
#sendToServer(data) {
|
|
258
|
-
if (!this.#
|
|
343
|
+
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
|
|
259
344
|
return console.error(ERROR_PREFIX + "Cannot send message - not connected.");
|
|
260
345
|
}
|
|
261
346
|
try {
|
|
@@ -280,7 +365,6 @@ class PlaySocket {
|
|
|
280
365
|
|
|
281
366
|
return Promise.race([
|
|
282
367
|
new Promise((resolve, reject) => {
|
|
283
|
-
this.#isHost = true;
|
|
284
368
|
this.#storage = initialStorage;
|
|
285
369
|
this.#roomId = this.#id; // Create room with your own ID as the room ID to mimic p2p
|
|
286
370
|
this.#roomHost = this.#id;
|
|
@@ -310,9 +394,7 @@ class PlaySocket {
|
|
|
310
394
|
|
|
311
395
|
return Promise.race([
|
|
312
396
|
new Promise((resolve, reject) => {
|
|
313
|
-
this.#isHost = false;
|
|
314
397
|
this.#roomId = roomId;
|
|
315
|
-
this.#roomHost = roomId;
|
|
316
398
|
this.#pendingJoin = { resolve, reject };
|
|
317
399
|
|
|
318
400
|
// Send connection request
|
|
@@ -333,12 +415,15 @@ class PlaySocket {
|
|
|
333
415
|
*/
|
|
334
416
|
updateStorage(key, value) {
|
|
335
417
|
if (JSON.stringify(this.#storage[key]) === JSON.stringify(value)) return; // Prevent updates without changes
|
|
418
|
+
const timestamp = this.#getTimestamp();
|
|
336
419
|
this.#sendToServer({
|
|
337
420
|
type: 'room_storage_update',
|
|
338
421
|
key,
|
|
339
|
-
value
|
|
422
|
+
value,
|
|
423
|
+
timestamp
|
|
340
424
|
});
|
|
341
425
|
this.#storage[key] = value;
|
|
426
|
+
this.#storageTimestamps[key] = timestamp;
|
|
342
427
|
this.#triggerEvent("storageUpdated", { ...this.#storage });
|
|
343
428
|
}
|
|
344
429
|
|
|
@@ -360,6 +445,7 @@ class PlaySocket {
|
|
|
360
445
|
updateValue
|
|
361
446
|
});
|
|
362
447
|
this.#storage[key] = updatedArray;
|
|
448
|
+
this.#storageTimestamps[key] = this.#getTimestamp();
|
|
363
449
|
this.#triggerEvent("storageUpdated", { ...this.#storage });
|
|
364
450
|
}
|
|
365
451
|
|
|
@@ -413,18 +499,20 @@ class PlaySocket {
|
|
|
413
499
|
}
|
|
414
500
|
|
|
415
501
|
// Reset state
|
|
416
|
-
this.#
|
|
502
|
+
clearTimeout(this.#reconnectTimeout);
|
|
417
503
|
this.#roomHost = null;
|
|
418
504
|
this.#storage = {};
|
|
505
|
+
this.#storageTimestamps = {};
|
|
419
506
|
this.#roomId = null;
|
|
420
507
|
this.#connectionCount = 0;
|
|
421
|
-
|
|
508
|
+
this.#isReconnecting = false;
|
|
509
|
+
this.#reconnectCount = 0;
|
|
422
510
|
}
|
|
423
511
|
|
|
424
512
|
// Public getters
|
|
425
513
|
get connectionCount() { return this.#connectionCount; }
|
|
426
514
|
get getStorage() { return { ...this.#storage }; }
|
|
427
|
-
get isHost() { return this.#
|
|
515
|
+
get isHost() { return this.#id == this.#roomHost; }
|
|
428
516
|
get id() { return this.#id; }
|
|
429
517
|
}
|
|
430
518
|
|
|
@@ -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;
|
|
@@ -117,6 +154,7 @@ function requireServer () {
|
|
|
117
154
|
participants: [ws.clientId],
|
|
118
155
|
maxSize: data.size || null,
|
|
119
156
|
storage: data.storage || {},
|
|
157
|
+
storageTimestamps: {},
|
|
120
158
|
};
|
|
121
159
|
|
|
122
160
|
this.#clientRooms.set(ws.clientId, newRoomId);
|
|
@@ -125,7 +163,7 @@ function requireServer () {
|
|
|
125
163
|
break;
|
|
126
164
|
|
|
127
165
|
case 'join_room':
|
|
128
|
-
if (!data.roomId) return;
|
|
166
|
+
if (!data.roomId || !ws.clientId) return;
|
|
129
167
|
const roomId = data.roomId;
|
|
130
168
|
const room = this.#rooms[roomId];
|
|
131
169
|
|
|
@@ -133,7 +171,7 @@ function requireServer () {
|
|
|
133
171
|
(room.maxSize && room.participants.length >= room.maxSize) ||
|
|
134
172
|
this.#clientRooms.get(ws.clientId)) {
|
|
135
173
|
ws.send(JSON.stringify({
|
|
136
|
-
type: '
|
|
174
|
+
type: 'join_rejected',
|
|
137
175
|
reason: !room ? 'Room not found' :
|
|
138
176
|
room.maxSize && room.participants.length >= room.maxSize ? 'Room full' :
|
|
139
177
|
'Already in a room'
|
|
@@ -146,9 +184,11 @@ function requireServer () {
|
|
|
146
184
|
|
|
147
185
|
// Notify joiner with initial storage
|
|
148
186
|
ws.send(JSON.stringify({
|
|
149
|
-
type: '
|
|
187
|
+
type: 'join_accepted',
|
|
150
188
|
storage: room.storage,
|
|
151
|
-
|
|
189
|
+
storageTimestamps: room.storageTimestamps,
|
|
190
|
+
participantCount: room.participants.length,
|
|
191
|
+
host: room.participants[0]
|
|
152
192
|
}));
|
|
153
193
|
|
|
154
194
|
// Notify existing participants
|
|
@@ -169,9 +209,13 @@ function requireServer () {
|
|
|
169
209
|
case 'room_storage_update':
|
|
170
210
|
const updateRoomId = this.#clientRooms.get(ws.clientId);
|
|
171
211
|
const updateRoom = updateRoomId ? this.#rooms[updateRoomId] : null;
|
|
172
|
-
if (updateRoom && data.key
|
|
173
|
-
updateRoom.
|
|
174
|
-
|
|
212
|
+
if (updateRoom && data.key) {
|
|
213
|
+
const existingTimestamp = updateRoom.storageTimestamps[data.key] || 0;
|
|
214
|
+
if (data.timestamp > existingTimestamp && JSON.stringify(updateRoom.storage[data.key]) !== JSON.stringify(data.value)) {
|
|
215
|
+
updateRoom.storage[data.key] = data.value;
|
|
216
|
+
updateRoom.storageTimestamps[data.key] = data.timestamp;
|
|
217
|
+
this.#syncRoomStorageKey(updateRoomId, data.key, data.timestamp);
|
|
218
|
+
}
|
|
175
219
|
}
|
|
176
220
|
break;
|
|
177
221
|
|
|
@@ -182,7 +226,8 @@ function requireServer () {
|
|
|
182
226
|
const updatedArray = this.#arrayUpdate(arrayRoom.storage, data.key, data.operation, data.value, data.updateValue);
|
|
183
227
|
if (JSON.stringify(arrayRoom.storage[data.key]) !== JSON.stringify(updatedArray)) {
|
|
184
228
|
arrayRoom.storage[data.key] = updatedArray;
|
|
185
|
-
|
|
229
|
+
arrayRoom.storageTimestamps[data.key] = Date.now(); // New date to ensure that they are applied regardless of age (they don't overwrite – they transform)
|
|
230
|
+
this.#syncRoomStorageKey(arrayRoomId, data.key, Date.now());
|
|
186
231
|
}
|
|
187
232
|
}
|
|
188
233
|
break;
|
|
@@ -192,6 +237,16 @@ function requireServer () {
|
|
|
192
237
|
}
|
|
193
238
|
}
|
|
194
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Generate a random token to prevent malicious reconnect attempts
|
|
242
|
+
* @returns {string} - Token
|
|
243
|
+
*/
|
|
244
|
+
#generateSessionToken() {
|
|
245
|
+
let token = '';
|
|
246
|
+
for (let i = 0; i < 16; i++) token += Math.floor(Math.random() * 16).toString(16);
|
|
247
|
+
return token;
|
|
248
|
+
}
|
|
249
|
+
|
|
195
250
|
/**
|
|
196
251
|
* Check rate limit using token bucket algorithm
|
|
197
252
|
* @private
|
|
@@ -224,7 +279,7 @@ function requireServer () {
|
|
|
224
279
|
* Sync storage key to all clients in room
|
|
225
280
|
* @private
|
|
226
281
|
*/
|
|
227
|
-
#syncRoomStorageKey(roomId, key) {
|
|
282
|
+
#syncRoomStorageKey(roomId, key, timestamp) {
|
|
228
283
|
const room = this.#rooms[roomId];
|
|
229
284
|
if (!room) return;
|
|
230
285
|
room.participants.forEach(p => {
|
|
@@ -233,7 +288,8 @@ function requireServer () {
|
|
|
233
288
|
client.send(JSON.stringify({
|
|
234
289
|
type: 'storage_sync',
|
|
235
290
|
key,
|
|
236
|
-
value: room.storage[key]
|
|
291
|
+
value: room.storage[key],
|
|
292
|
+
timestamp
|
|
237
293
|
}));
|
|
238
294
|
}
|
|
239
295
|
});
|
|
@@ -276,37 +332,40 @@ function requireServer () {
|
|
|
276
332
|
*/
|
|
277
333
|
#handleDisconnection(ws) {
|
|
278
334
|
if (!ws.clientId) return;
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
335
|
+
this.#clients.delete(ws.clientId); // Immediately remove from active clients (otherwise, server would try to message this client)
|
|
336
|
+
|
|
337
|
+
// Pending disconnection with 2s grace period to allow for reconnections
|
|
338
|
+
this.#pendingDisconnects.set(ws.clientId, {
|
|
339
|
+
timeout: setTimeout(() => {
|
|
340
|
+
this.#pendingDisconnects.delete(ws.clientId);
|
|
341
|
+
this.#rateLimits.delete(ws.clientId);
|
|
342
|
+
this.#clientTokens.delete(ws.clientId);
|
|
343
|
+
const roomId = this.#clientRooms.get(ws.clientId);
|
|
344
|
+
const room = this.#rooms[roomId];
|
|
345
|
+
|
|
346
|
+
if (room) {
|
|
347
|
+
room.participants = room.participants.filter(p => p !== ws.clientId); // Remove client from room
|
|
348
|
+
this.#clientRooms.delete(ws.clientId);
|
|
349
|
+
|
|
350
|
+
if (room.participants?.length === 0) {
|
|
351
|
+
delete this.#rooms[roomId]; // Delete room if now empty
|
|
352
|
+
} else {
|
|
353
|
+
// Notify remaining participants
|
|
354
|
+
room.participants.forEach(p => {
|
|
355
|
+
const client = this.#clients.get(p);
|
|
356
|
+
if (client) client.send(JSON.stringify({
|
|
357
|
+
type: 'client_disconnected',
|
|
358
|
+
client: ws.clientId,
|
|
359
|
+
host: room.participants[0],
|
|
360
|
+
participantCount: room.participants.length
|
|
361
|
+
}));
|
|
362
|
+
});
|
|
303
363
|
}
|
|
304
364
|
}
|
|
305
|
-
});
|
|
306
|
-
if (room.participants?.length === 0) delete this.#rooms[roomId]; // Delete room if now empty
|
|
307
|
-
}
|
|
308
365
|
|
|
309
|
-
|
|
366
|
+
this.#triggerEvent("clientDisconnected", ws.clientId);
|
|
367
|
+
}, 2000)
|
|
368
|
+
});
|
|
310
369
|
}
|
|
311
370
|
|
|
312
371
|
/**
|
|
@@ -346,6 +405,9 @@ function requireServer () {
|
|
|
346
405
|
console.log('PlaySocket server stopped');
|
|
347
406
|
});
|
|
348
407
|
}
|
|
408
|
+
this.#pendingDisconnects.forEach((data) => {
|
|
409
|
+
clearTimeout(data.timeout);
|
|
410
|
+
});
|
|
349
411
|
}
|
|
350
412
|
|
|
351
413
|
get getRooms() { return { ...this.#rooms } }
|