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