playsocketjs 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Paul Rhomberg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # PlaySocket
2
+
3
+ A WebSocket-based multiplayer library that simplifies game development by abstracting away the backend logic and moving it to the frontend.
4
+
5
+ ## Why use PlaySocket?
6
+
7
+ PlaySocket eliminates the traditional complexity of multiplayer implementations:
8
+
9
+ - **Streamlined Architecture**: No additional backend code is required
10
+ - **State Synchronization**: Built-in storage system keeps game state synchronized across all users
11
+ - **Resilient & Secure Connections**: Automatic reconnection handling and rate limiting
12
+ - **Lightweight**: Uses WebSockets for efficient, predictable & reliable communication with little code required
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install playsocketjs
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ Note that in production, you should **always try...catch** these promises, such as socket.init(), to ensure your application continues to run if errors occur.
23
+
24
+ ```javascript
25
+ import PlaySocket from 'playsocketjs';
26
+
27
+ // Create a new instance
28
+ const socket = new PlaySocket('unique-client-id', {
29
+ endpoint: 'wss://example.com/socket'
30
+ });
31
+
32
+ // Set up event handlers
33
+ socket.onEvent('status', status => console.log('Status:', status));
34
+ socket.onEvent('storageUpdated', storage => console.log('Storage update received:', storage));
35
+
36
+ // Initialize the socket
37
+ await socket.init();
38
+
39
+ // Create a new room
40
+ const roomId = await socket.createRoom();
41
+
42
+ // Optionally, you can create a room with an initial storage object
43
+ const roomId = await socket.createRoom({
44
+ players: [],
45
+ moreThings: {},
46
+ latestPlayer: null,
47
+ });
48
+
49
+ // Join an existing room
50
+ await socket.joinRoom('room-id');
51
+
52
+ // Interact with the synced storage (available if in room)
53
+ const currentState = socket.getStorage;
54
+ socket.updateStorageArray('players', 'add-unique', { username: 'Player4', level: 2 }); // Special method to enable safe, simultaneous storage updates for arrays
55
+ socket.updateStorage('latestPlayer', 'Player4'); // Regular synced storage update
56
+
57
+ // To leave the room, destroy the instance
58
+ socket.destroy();
59
+ ```
60
+
61
+ ## API Reference
62
+
63
+ ### Constructor
64
+
65
+ ```javascript
66
+ new PlaySocket(id: string, options: PlaySocketOptions)
67
+ ```
68
+
69
+ Creates a new PlaySocket instance with a specified ID and configuration options.
70
+
71
+ #### Configuration options
72
+ - `endpoint`: WebSocket server endpoint (e.g., 'wss://example.com/socket')
73
+ - `customData`: You can pass arbitrary data to the "clientRegistered" server event (optional)
74
+
75
+ ### Methods
76
+
77
+ #### Core
78
+
79
+ - `init()`: Initialize the WebSocket connection – Returns Promise (async)
80
+ - `createRoom(initialStorage?: object, maxSize?: number)`: Create a new room and become host – Returns Promise (async) which resolves with the room id (matches the creator's id)
81
+ - `joinRoom(hostId: string)`: Join an existing room – Returns Promise (async)
82
+ - `destroy()`: Use this to leave a room and close the connection
83
+
84
+ #### State management
85
+
86
+ - `updateStorage(key: string, value: any)`: Update a value in the synchronized storage
87
+ - `updateStorageArray(key: string, operation: 'add' | 'add-unique' | 'remove-matching' | 'update-matching', value: any, updateValue?: any)`: Safely update arrays in storage by adding, removing, or updating items. This is necessary for when array updates might be happening simultaneously to ensure changes are being applied and not overwritten. Using add-unique instead of add ensures that this value can only be in the array once.
88
+ - `onEvent(event: string, callback: Function)`: Register an event callback
89
+
90
+ ##### Event types
91
+
92
+ - `status`: Connection status updates (returns status `string`)
93
+ - `error`: Error events (returns error `string`)
94
+ - `instanceDestroyed`: Destruction event - triggered by manual .destroy() method invocation or by fatal errors and disconnects
95
+ - `storageUpdated`: Storage state changes (returns storage `object`)
96
+ - `hostMigrated`: Host changes (returns the new host's id `string`) – As compared to PlayPeerJS, the room id does NOT change when the host changes
97
+ - `clientConnected`: New client connected to the room (returns client-id `string`)
98
+ - `clientDisconnected`: Client disconnected from the room (returns client-id `string`)
99
+
100
+ ### Properties (Read-only)
101
+
102
+ The `id` is used to distinguish the client from other clients on the WebSocket server.
103
+ Using a UUID is recommended, but it is also fine to use any other random string. If you're using a public WebSocket server, including your application's name in the `id` can help to prevent overlap (e.g. your-app-012345abcdef).
104
+
105
+ - `id`: Client's unique identifier
106
+ - `isHost`: If this user is currently assigned the host role
107
+ - `connectionCount`: Number of active client connections in room (without you)
108
+ - `getStorage`: Retrieve storage object
109
+
110
+ # PlaySocket Server
111
+
112
+ PlaySocket includes a server implementation that can be set up in seconds.
113
+
114
+ ## Installation
115
+
116
+ To use the server component, you'll need to install playsocketjs and the ws package:
117
+
118
+ ```bash
119
+ npm install playsocketjs ws
120
+ ```
121
+
122
+ ## Usage
123
+
124
+ Here are usage examples for a standalone server and an express application. The implementation is
125
+ framework agnostic and the Express example can be adapted to any other backend solution.
126
+
127
+ ### Standalone server
128
+
129
+ ```javascript
130
+ const PlaySocketServer = require('playsocketjs/server');
131
+
132
+ // Create and start the server
133
+ const server = new PlaySocketServer();
134
+ ```
135
+
136
+ ### With Express.js
137
+
138
+ ```javascript
139
+ const express = require('express');
140
+ const http = require('http');
141
+ const PlaySocketServer = require('playsocketjs/server');
142
+ const port = 3000;
143
+
144
+ const app = express();
145
+ const httpServer = http.createServer(app);
146
+
147
+ // Create PlaySocket server with your HTTP server
148
+ // Uses a custom path here (which you'll likely want to do if used this way)
149
+ const playSocketServer = new PlaySocketServer({
150
+ port,
151
+ server: httpServer,
152
+ path: '/socket'
153
+ });
154
+
155
+ // Start the server
156
+ httpServer.listen(port, () => {
157
+ console.log('Server running on port 3000');
158
+ });
159
+ ```
160
+
161
+ ## API Reference
162
+
163
+ ### Constructor
164
+
165
+ ```javascript
166
+ new PlaySocket(options: PlaySocketServerOptions)
167
+ ```
168
+
169
+ Creates a new PlaySocket Server instance with configuration options.
170
+
171
+ ### Configuration options
172
+
173
+ - `port`: Port to listen on (default: 3000, used only if no server provided)
174
+ - `path`: WebSocket endpoint path (default: '/')
175
+ - `server`: Existing http server (optional)
176
+
177
+ ### Methods
178
+
179
+ - `onEvent(event: string, callback: Function)`: Register a server-side event callback
180
+
181
+ ##### Event types
182
+
183
+ - `clientRegistered`: Client registered with the server (returns the client's id `string`, customData `object`)
184
+ - `clientDisconnected`: Client disconnected from the server (returns the client's id `string`)
185
+ - `roomCreated`: Client created a room (returns room id `string`)
186
+ - `roomJoined`: Client joined a room (returns the client's id `string`, room id `string`)
187
+
188
+ ### Properties (Read-only)
189
+
190
+ - `getRooms`: Retrieve the rooms object
191
+
192
+ # License
193
+
194
+ MIT
195
+
196
+ # Contributing
197
+
198
+ Please feel free to fork the repository and submit a Pull Request.
@@ -0,0 +1,427 @@
1
+ /**
2
+ * PlaySocket - WebSocket-based multiplayer for games
3
+ */
4
+
5
+ const ERROR_PREFIX = "PlaySocket error: ";
6
+ const WARNING_PREFIX = "PlaySocket warning: ";
7
+ const TIMEOUT_MS = 3000; // 3 second timeout for operations
8
+
9
+ class PlaySocket {
10
+ // Core properties
11
+ #id; // Unique client ID
12
+ #endpoint;
13
+ #socket; // WebSocket connection
14
+ #initialized = false; // Initialization status
15
+ #customData;
16
+
17
+ // Room properties
18
+ #storage = {}; // Shared storage object
19
+ #isHost = false; // Whether this client is host
20
+ #roomHost;
21
+ #roomId; // ID of the host (client only)
22
+ #connectionCount = 0;
23
+
24
+ // Event handling
25
+ #callbacks = new Map(); // Event callbacks
26
+
27
+ // Async server operations
28
+ #pendingJoin;
29
+ #pendingHost;
30
+ #pendingInit;
31
+
32
+ /**
33
+ * Create a new PlaySocket instance
34
+ * @param {string} id - Unique identifier for this client
35
+ * @param {object} options - Connection options
36
+ * @param {string} options.endpoint - WebSocket endpoint path
37
+ * @param {object} [options.customData] - Custom registration data
38
+ */
39
+ constructor(id, options = {}) {
40
+ this.#id = id;
41
+ if (options.endpoint) this.#endpoint = options.endpoint;
42
+ if (options.customData) this.#customData = { ...options.customData };
43
+ }
44
+
45
+ /**
46
+ * Helper to create timeout promises for create room, join room etc.
47
+ * @private
48
+ */
49
+ #createTimeout(operation) {
50
+ return new Promise((_, reject) =>
51
+ setTimeout(() => reject(new Error(`${operation} timed out`)), TIMEOUT_MS)
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Register an event callback
57
+ * @param {string} event - Event name
58
+ * @param {Function} callback - Callback function
59
+ */
60
+ onEvent(event, callback) {
61
+ const validEvents = [
62
+ "status", "error", "instanceDestroyed", "storageUpdated", "hostMigrated", "clientConnected", "clientDisconnected",
63
+ ];
64
+ if (!validEvents.includes(event)) return console.warn(WARNING_PREFIX + `Invalid event type "${event}".`);
65
+ if (!this.#callbacks.has(event)) this.#callbacks.set(event, []);
66
+ this.#callbacks.get(event).push(callback);
67
+ }
68
+
69
+ /**
70
+ * Trigger an event to registered callbacks
71
+ * @private
72
+ */
73
+ #triggerEvent(event, ...args) {
74
+ const callbacks = this.#callbacks.get(event);
75
+ callbacks?.forEach(callback => {
76
+ try {
77
+ callback(...args);
78
+ } catch (error) {
79
+ console.error(ERROR_PREFIX + `${event} callback error:`, error);
80
+ }
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Initialize the PlaySocket instance by connecting to the WS server
86
+ * @returns {Promise} Resolves when connection is established
87
+ */
88
+ async init() {
89
+ if (this.#initialized) return Promise.reject(new Error("Already initialized."));
90
+ if (!this.#endpoint) return Promise.reject(new Error("No websocket endpoint provided."));
91
+ this.#triggerEvent("status", "Initializing...");
92
+
93
+ return Promise.race([
94
+ this.#connect(),
95
+ this.#createTimeout("Initialization")
96
+ ]);
97
+ }
98
+
99
+
100
+ /**
101
+ * Connect to the WS server and register
102
+ * @returns {Promise} Resolves when user is registered on the WS server
103
+ * @private
104
+ */
105
+ async #connect() {
106
+ return new Promise((resolve, reject) => {
107
+ this.#socket = new WebSocket(this.#endpoint);
108
+ this.#setupSocketHandlers(); // Message & close events
109
+
110
+ // Resolve or reject depending on answer to registration msg
111
+ this.#pendingInit = { resolve, reject };
112
+
113
+ // Register with server
114
+ this.#socket.onopen = () => {
115
+ this.#initialized = true;
116
+ this.#sendToServer({
117
+ type: 'register',
118
+ id: this.#id,
119
+ customData: this.#customData || undefined
120
+ });
121
+ };
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Set up WebSocket message & close handlers
127
+ * @private
128
+ */
129
+ #setupSocketHandlers() {
130
+ this.#socket.onmessage = (event) => {
131
+ try {
132
+ const message = JSON.parse(event.data);
133
+ if (!message.type) return;
134
+
135
+ switch (message.type) {
136
+ case 'connection_accepted':
137
+ // Connected to room
138
+ if (this.#pendingJoin) {
139
+ this.#storage = message.storage;
140
+ this.#connectionCount = message.participantCount - 1;
141
+ this.#triggerEvent("status", `Connected to room.`);
142
+ this.#triggerEvent("storageUpdated", { ...this.#storage });
143
+ this.#pendingJoin.resolve();
144
+ this.#pendingJoin = null;
145
+ }
146
+ break;
147
+
148
+ case 'connection_rejected':
149
+ // Connection to room failed
150
+ if (this.#pendingJoin) {
151
+ this.#triggerEvent("status", "Connection rejected: " + message.reason || 'Unknown reason');
152
+ this.#pendingJoin.reject(new Error("Connection rejected: " + message.reason || 'Unknown reason'));
153
+ this.#pendingJoin = null;
154
+ }
155
+ break;
156
+
157
+ case 'room_created':
158
+ if (this.#pendingHost) {
159
+ this.#pendingHost.resolve(this.#roomId);
160
+ this.#triggerEvent("status", `Room created${this.#pendingHost.maxSize ? ` with max size ${this.#pendingHost.maxSize}.` : '.'}`);
161
+ this.#triggerEvent("storageUpdated", { ...this.#storage });
162
+ this.#pendingHost = null;
163
+ }
164
+ break;
165
+
166
+ case 'room_creation_failed':
167
+ if (this.#pendingHost) {
168
+ this.#pendingHost.reject(new Error("Failed to create room: " + message.reason || 'Unknown reason'));
169
+ this.#triggerEvent("error", "Failed to create room: " + message.reason || 'Unknown reason');
170
+ this.#pendingHost = null;
171
+ }
172
+ break;
173
+
174
+ case 'id_taken':
175
+ if (this.#pendingInit) {
176
+ this.#pendingInit.reject(new Error("This id is already in use."));
177
+ this.#triggerEvent("error", "This id is taken.");
178
+ this.#pendingInit = null;
179
+ }
180
+ break;
181
+
182
+ case 'registered':
183
+ if (this.#pendingInit) {
184
+ this.#pendingInit.resolve();
185
+ this.#triggerEvent("status", "Connected to server.");
186
+ this.#pendingInit = null;
187
+ }
188
+ break;
189
+
190
+ case 'storage_sync':
191
+ if (message.key && JSON.stringify(this.#storage[message.key]) !== JSON.stringify(message.value)) {
192
+ this.#storage[message.key] = message.value;
193
+ this.#triggerEvent("storageUpdated", { ...this.#storage });
194
+ }
195
+ break;
196
+
197
+ case 'client_disconnected':
198
+ this.#isHost = this.#id == message.updatedHost;
199
+ this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
200
+
201
+ // If host has changed...
202
+ if (this.#roomHost != message.updatedHost) {
203
+ this.#roomHost = message.updatedHost;
204
+ this.#triggerEvent("hostMigrated", this.#roomHost);
205
+ }
206
+
207
+ this.#triggerEvent("clientDisconnected", message.client);
208
+ this.#triggerEvent("status", `Client ${message.client} disconnected.`);
209
+ break;
210
+
211
+ case 'client_connected':
212
+ this.#connectionCount = message.participantCount - 1;
213
+ this.#triggerEvent("clientConnected", message.client);
214
+ this.#triggerEvent("status", `Client ${message.client} connected.`);
215
+ break;
216
+ }
217
+ } catch (error) {
218
+ console.error(ERROR_PREFIX + "Error handling message:", error);
219
+ }
220
+ };
221
+
222
+ // Handle socket errors
223
+ this.#socket.onerror = () => {
224
+ this.#triggerEvent("error", "WebSocket error.");
225
+ if (this.#pendingInit) this.#pendingInit.reject(new Error("WebSocket error."));
226
+ };
227
+
228
+ // Handle socket close & attempt reconnect
229
+ this.#socket.onclose = () => {
230
+ this.#triggerEvent("status", "Disconnected from server.");
231
+
232
+ setTimeout(async () => {
233
+ if (!this.#initialized || !this.#socket) return;
234
+ try {
235
+ await this.#connect(); // Will set status to connected if successful
236
+
237
+ // Rejoin room if needed
238
+ if (this.#roomId) {
239
+ this.#triggerEvent("status", "Rejoining room...");
240
+ await this.joinRoom(this.#roomId); // If this fails, it will be caught by the outer catch
241
+ }
242
+ } catch (error) {
243
+ this.#triggerEvent("error", "WebSocket connection permanently closed: " + error);
244
+ this.#socket = null;
245
+ this.destroy();
246
+ }
247
+ }, 1000);
248
+ };
249
+ };
250
+
251
+ /**
252
+ * Send a message to the server
253
+ * @private
254
+ */
255
+ #sendToServer(data) {
256
+ if (!this.#initialized || !this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
257
+ return console.error(ERROR_PREFIX + "Cannot send message - not connected.");
258
+ }
259
+ try {
260
+ this.#socket.send(JSON.stringify(data));
261
+ } catch (error) {
262
+ console.error(ERROR_PREFIX + "Error sending message:", error);
263
+ this.#triggerEvent("error", "Error sending message: " + error.message);
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Create a new room and become host
269
+ * @param {object} initialStorage - Initial state
270
+ * @param {number} maxSize - Max number of participants
271
+ * @returns {Promise} Resolves with room ID
272
+ */
273
+ createRoom(initialStorage = {}, maxSize) {
274
+ if (!this.#initialized) {
275
+ this.#triggerEvent("error", "Cannot create room - not initialized");
276
+ return Promise.reject(new Error("Not initialized"));
277
+ }
278
+
279
+ return Promise.race([
280
+ new Promise((resolve, reject) => {
281
+ this.#isHost = true;
282
+ this.#storage = initialStorage;
283
+ this.#roomId = this.#id; // Create room with your own ID as the room ID to mimic p2p
284
+ this.#roomHost = this.#id;
285
+ this.#pendingHost = { maxSize, resolve, reject };
286
+
287
+ this.#sendToServer({
288
+ type: 'create_room',
289
+ storage: { ...initialStorage },
290
+ size: maxSize,
291
+ from: this.#id,
292
+ });
293
+ }),
294
+ this.#createTimeout("Room creation")
295
+ ]);
296
+ }
297
+
298
+ /**
299
+ * Join an existing room
300
+ * @param {string} roomId - ID of the room
301
+ * @returns {Promise} Resolves when connected
302
+ */
303
+ joinRoom(roomId) {
304
+ if (!this.#initialized) {
305
+ this.#triggerEvent("error", "Cannot join room - not initialized");
306
+ return Promise.reject(new Error("Not initialized"));
307
+ }
308
+
309
+ return Promise.race([
310
+ new Promise((resolve, reject) => {
311
+ this.#isHost = false;
312
+ this.#roomId = roomId;
313
+ this.#roomHost = roomId;
314
+ this.#pendingJoin = { resolve, reject };
315
+
316
+ // Send connection request
317
+ this.#triggerEvent("status", `Connecting to room ${roomId}...`);
318
+ this.#sendToServer({
319
+ type: 'join_room',
320
+ roomId
321
+ });
322
+ }),
323
+ this.#createTimeout("Room join")
324
+ ]);
325
+ }
326
+
327
+ /**
328
+ * Update a value in the shared storage
329
+ * @param {string} key - Storage key
330
+ * @param {*} value - New value
331
+ */
332
+ updateStorage(key, value) {
333
+ if (JSON.stringify(this.#storage[key]) === JSON.stringify(value)) return;
334
+ this.#storage[key] = value;
335
+ this.#triggerEvent("storageUpdated", { ...this.#storage });
336
+ this.#sendToServer({
337
+ type: 'room_storage_update',
338
+ key,
339
+ value
340
+ });
341
+ }
342
+
343
+ /**
344
+ * Update an array in storage with special operations
345
+ * @param {string} key - Storage key
346
+ * @param {string} operation - Operation type: add, add-unique, remove-matching, update-matching
347
+ * @param {*} value - Value to operate on
348
+ * @param {*} updateValue - New value for update-matching
349
+ */
350
+ updateStorageArray(key, operation, value, updateValue) {
351
+ this.#handleArrayUpdate(key, operation, value, updateValue);
352
+ this.#sendToServer({
353
+ type: 'room_storage_array_update',
354
+ key,
355
+ operation,
356
+ value,
357
+ updateValue
358
+ });
359
+ }
360
+
361
+ /**
362
+ * Handle array operations client-side
363
+ * @private
364
+ */
365
+ #handleArrayUpdate(key, operation, value, updateValue) {
366
+ if (!this.#storage[key] || !Array.isArray(this.#storage[key])) this.#storage[key] = [];
367
+ let array = this.#storage[key];
368
+
369
+ const isObject = typeof value === 'object' && value !== null;
370
+ const compare = (item) => isObject ?
371
+ JSON.stringify(item) === JSON.stringify(value) : item === value;
372
+
373
+ switch (operation) {
374
+ case 'add':
375
+ array.push(value);
376
+ break;
377
+
378
+ case 'add-unique':
379
+ if (!array.some(compare)) array.push(value);
380
+ break;
381
+
382
+ case 'remove-matching':
383
+ this.#storage[key] = array.filter(item => !compare(item));
384
+ break;
385
+
386
+ case 'update-matching':
387
+ const index = array.findIndex(compare);
388
+ if (index !== -1) array[index] = updateValue;
389
+ break;
390
+
391
+ default:
392
+ console.error(ERROR_PREFIX + `Unknown array operation: ${operation}`);
393
+ return;
394
+ }
395
+
396
+ this.#triggerEvent("storageUpdated", { ...this.#storage });
397
+ }
398
+
399
+ /**
400
+ * Destroy the PlaySocket instance and disconnect
401
+ */
402
+ destroy() {
403
+ if (this.#socket) {
404
+ this.#socket.close();
405
+ this.#socket = null;
406
+ }
407
+
408
+ // Reset state
409
+ this.#initialized = false;
410
+ this.#isHost = false;
411
+ this.#roomHost = null;
412
+ this.#storage = {};
413
+ this.#roomId = null;
414
+ this.#connectionCount = 0;
415
+
416
+ this.#triggerEvent("status", "Destroyed.");
417
+ this.#triggerEvent("instanceDestroyed");
418
+ }
419
+
420
+ // Public getters
421
+ get connectionCount() { return this.#connectionCount; }
422
+ get getStorage() { return { ...this.#storage }; }
423
+ get isHost() { return this.#isHost; }
424
+ get id() { return this.#id; }
425
+ }
426
+
427
+ export { PlaySocket as default };
@@ -0,0 +1,349 @@
1
+ 'use strict';
2
+
3
+ var require$$0 = require('ws');
4
+ var require$$1 = require('http');
5
+
6
+ function getDefaultExportFromCjs (x) {
7
+ return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
8
+ }
9
+
10
+ var server$1;
11
+ var hasRequiredServer;
12
+
13
+ function requireServer () {
14
+ if (hasRequiredServer) return server$1;
15
+ hasRequiredServer = 1;
16
+ const WebSocket = require$$0;
17
+ const http = require$$1;
18
+
19
+ /**
20
+ * PlaySocketServer - WebSocket server for PlaySocket multiplayer library
21
+ */
22
+ class PlaySocketServer {
23
+ #server;
24
+ #wss;
25
+ #clients = new Map(); // ClientId -> WebSocket instance
26
+ #rooms = {};
27
+ #clientRooms = new Map(); // ClientId -> RoomId
28
+ #rateLimits = new Map(); // Rate limiting storage
29
+ #callbacks = new Map();
30
+
31
+ /**
32
+ * Create a new PlaySocketServer instance
33
+ * @param {Object} options - Server configuration options
34
+ * @param {HttpServer} [options.server] - Existing http server
35
+ * @param {number} [options.port=3000] - Port to listen on (if no server provided)
36
+ * @param {string} [options.path='/socket'] - WebSocket endpoint path
37
+ */
38
+ constructor(options = {}) {
39
+ const { server, port = 3000, path = '/' } = options;
40
+
41
+ // Handle server creation / usage
42
+ if (server) {
43
+ this.#server = server; // Use provided HTTP server
44
+ } else {
45
+ this.#server = http.createServer(); // Create HTTP server and start it
46
+ this.#server.listen(port, () => {
47
+ console.log(`PlaySocket server running on port ${port}`);
48
+ });
49
+ }
50
+
51
+ // Create WebSocket server
52
+ this.#wss = new WebSocket.Server({
53
+ server: this.#server,
54
+ path
55
+ });
56
+
57
+ // Set up ws event handlers
58
+ this.#wss.on('connection', ws => {
59
+ ws.on('message', msg => this.#handleMessage(ws, msg));
60
+ ws.on('close', () => this.#handleDisconnection(ws));
61
+ });
62
+
63
+ process.on('SIGINT', () => this.#stop()); // Graceful shutdown
64
+ }
65
+
66
+ /**
67
+ * Handle WebSocket message
68
+ * @private
69
+ */
70
+ #handleMessage(ws, message) {
71
+ try {
72
+ const data = JSON.parse(message);
73
+
74
+ // Apply rate limiting after client is registered
75
+ if (ws.clientId && !this.#checkRateLimit(ws.clientId, data.type)) {
76
+ return console.error(`Client ${ws.clientId} rate limit exceeded.`);
77
+ }
78
+
79
+ switch (data.type) {
80
+ case 'register':
81
+ // Register client ID
82
+ if (!data.id) return;
83
+ if (this.#clients.get(data.id)) {
84
+ ws.send(JSON.stringify({ type: 'id_taken' }));
85
+ return;
86
+ }
87
+ ws.clientId = data.id; // Adds the provided id to the ws object as clientId
88
+ this.#clients.set(data.id, ws);
89
+ ws.send(JSON.stringify({ type: 'registered' }));
90
+ this.#triggerEvent("clientRegistered", data.id, data.customData);
91
+ break;
92
+
93
+ case 'create_room':
94
+ if (!ws.clientId) return;
95
+ const newRoomId = ws.clientId;
96
+ if (this.#clientRooms.get(ws.clientId) || this.#rooms[newRoomId]) {
97
+ ws.send(JSON.stringify({
98
+ type: 'room_creation_failed',
99
+ reason: this.#clientRooms.get(ws.clientId) ? 'Already in a room' : 'Room id is taken'
100
+ }));
101
+ return;
102
+ }
103
+
104
+ this.#rooms[newRoomId] = {
105
+ participants: [ws.clientId],
106
+ maxSize: data.size || null,
107
+ storage: data.storage || {},
108
+ };
109
+
110
+ this.#clientRooms.set(ws.clientId, newRoomId);
111
+ ws.send(JSON.stringify({ type: 'room_created' }));
112
+ this.#triggerEvent("roomCreated", ws.clientId); // Client id = room id
113
+ break;
114
+
115
+ case 'join_room':
116
+ if (!data.roomId) return;
117
+ const roomId = data.roomId;
118
+ const room = this.#rooms[roomId];
119
+
120
+ if (!room ||
121
+ (room.maxSize && room.participants.length >= room.maxSize) ||
122
+ this.#clientRooms.get(ws.clientId)) {
123
+ ws.send(JSON.stringify({
124
+ type: 'connection_rejected',
125
+ reason: !room ? 'Room not found' :
126
+ room.maxSize && room.participants.length >= room.maxSize ? 'Room full' :
127
+ 'Already in a room'
128
+ }));
129
+ return;
130
+ }
131
+
132
+ room.participants.push(ws.clientId);
133
+ this.#clientRooms.set(ws.clientId, roomId);
134
+
135
+ // Notify joiner with initial storage
136
+ ws.send(JSON.stringify({
137
+ type: 'connection_accepted',
138
+ storage: room.storage,
139
+ participantCount: room.participants.length
140
+ }));
141
+
142
+ // Notify existing participants
143
+ room.participants.forEach(p => {
144
+ if (p === ws.clientId) return;
145
+ const client = this.#clients.get(p);
146
+ if (client) {
147
+ client.send(JSON.stringify({
148
+ type: 'client_connected',
149
+ client: ws.clientId,
150
+ participantCount: room.participants.length
151
+ }));
152
+ }
153
+ });
154
+ this.#triggerEvent("roomJoined", ws.clientId, roomId);
155
+ break;
156
+
157
+ case 'room_storage_update':
158
+ const updateRoomId = this.#clientRooms.get(ws.clientId);
159
+ const updateRoom = updateRoomId ? this.#rooms[updateRoomId] : null;
160
+ if (updateRoom && data.key) {
161
+ updateRoom.storage[data.key] = data.value;
162
+ this.#syncRoomStorageKey(updateRoomId, data.key);
163
+ }
164
+ break;
165
+
166
+ case 'room_storage_array_update':
167
+ const arrayRoomId = this.#clientRooms.get(ws.clientId);
168
+ const arrayRoom = arrayRoomId ? this.#rooms[arrayRoomId] : null;
169
+ if (arrayRoom && data.key) {
170
+ this.#arrayUpdate(arrayRoom.storage, data.key, data.operation, data.value, data.updateValue);
171
+ this.#syncRoomStorageKey(arrayRoomId, data.key);
172
+ }
173
+ break;
174
+ }
175
+ } catch (error) {
176
+ console.error('Error processing message:', error);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Check rate limit using token bucket algorithm
182
+ * @private
183
+ */
184
+ #checkRateLimit(clientId, operationType) {
185
+ const MAX_POINTS = 100;
186
+ const now = Date.now();
187
+
188
+ if (!this.#rateLimits.has(clientId)) {
189
+ this.#rateLimits.set(clientId, { points: MAX_POINTS, lastReset: now });
190
+ return true;
191
+ }
192
+
193
+ const limit = this.#rateLimits.get(clientId);
194
+
195
+ // Reset points if interval has passed (5s)
196
+ if (now - limit.lastReset > 5000) {
197
+ limit.points = MAX_POINTS;
198
+ limit.lastReset = now;
199
+ }
200
+
201
+ const pointCost = ['create_room', 'join_room'].includes(operationType) ? 20 : 1;
202
+ if (limit.points < pointCost) return false;
203
+
204
+ limit.points -= pointCost;
205
+ return true;
206
+ }
207
+
208
+ /**
209
+ * Sync storage key to all clients in room
210
+ * @private
211
+ */
212
+ #syncRoomStorageKey(roomId, key) {
213
+ const room = this.#rooms[roomId];
214
+ if (!room) return;
215
+
216
+ room.participants.forEach(p => {
217
+ const client = this.#clients.get(p);
218
+ if (client) {
219
+ client.send(JSON.stringify({
220
+ type: 'storage_sync',
221
+ key,
222
+ value: room.storage[key]
223
+ }));
224
+ }
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Handle array operations
230
+ * @private
231
+ */
232
+ #arrayUpdate(storage, key, operation, value, updateValue) {
233
+ if (!storage[key] || !Array.isArray(storage[key])) storage[key] = [];
234
+ let array = storage[key];
235
+
236
+ const isObject = typeof value === 'object' && value !== null;
237
+ const compare = (item) => isObject ?
238
+ JSON.stringify(item) === JSON.stringify(value) : item === value;
239
+
240
+ switch (operation) {
241
+ case 'add':
242
+ array.push(value);
243
+ break;
244
+
245
+ case 'add-unique':
246
+ if (!array.some(compare)) array.push(value);
247
+ break;
248
+
249
+ case 'remove-matching':
250
+ storage[key] = array.filter(item => !compare(item));
251
+ break;
252
+
253
+ case 'update-matching':
254
+ const index = array.findIndex(compare);
255
+ if (index !== -1) array[index] = updateValue;
256
+ break;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Handle client disconnection
262
+ * @private
263
+ */
264
+ #handleDisconnection(ws) {
265
+ if (!ws.clientId) return;
266
+
267
+ this.#clients.delete(ws.clientId);
268
+ this.#rateLimits.delete(ws.clientId);
269
+
270
+ const roomId = this.#clientRooms.get(ws.clientId);
271
+ const room = roomId ? this.#rooms[roomId] : null;
272
+
273
+ if (room) {
274
+ room.participants = room.participants?.filter(p => p !== ws.clientId);
275
+ this.#clientRooms.delete(ws.clientId);
276
+
277
+ // Notify remaining participants
278
+ room.participants?.forEach(p => {
279
+ const client = this.#clients.get(p);
280
+ if (client) {
281
+ try {
282
+ client.send(JSON.stringify({
283
+ type: 'client_disconnected',
284
+ updatedHost: room.participants?.[0],
285
+ client: ws.clientId,
286
+ participantCount: room.participants?.length
287
+ }));
288
+ } catch (error) {
289
+ console.error(`Error notifying client of disconnection:`, error);
290
+ }
291
+ }
292
+ });
293
+ if (room.participants?.length === 0) delete this.#rooms[roomId]; // Delete room if now empty
294
+ }
295
+
296
+ this.#triggerEvent("clientDisconnected", ws.clientId);
297
+ }
298
+
299
+ /**
300
+ * Register an event callback
301
+ * @param {string} event - Event name
302
+ * @param {Function} callback - Callback function
303
+ */
304
+ onEvent(event, callback) {
305
+ const validEvents = ["clientRegistered", "clientDisconnected", "roomCreated", "roomJoined"];
306
+ if (!validEvents.includes(event)) return console.warn(`Invalid PlaySocket event type "${event}"`);
307
+ if (!this.#callbacks.has(event)) this.#callbacks.set(event, []);
308
+ this.#callbacks.get(event).push(callback);
309
+ }
310
+
311
+ /**
312
+ * Trigger an event to registered callbacks
313
+ * @private
314
+ */
315
+ #triggerEvent(event, ...args) {
316
+ const callbacks = this.#callbacks.get(event);
317
+ callbacks?.forEach(callback => {
318
+ try { callback(...args); } catch (error) { console.error(`PlaySocket ${event} callback error:`, error); }
319
+ });
320
+ }
321
+
322
+ /**
323
+ * Stop the server
324
+ * @private
325
+ */
326
+ #stop() {
327
+ if (this.#wss) {
328
+ this.#wss.clients.forEach(client => client.close());
329
+ this.#wss.close(); // TODO necessary?
330
+ }
331
+
332
+ if (this.#server) {
333
+ this.#server.close(() => {
334
+ console.log('PlaySocket server stopped');
335
+ });
336
+ }
337
+ }
338
+
339
+ get getRooms() { return { ...this.#rooms } }
340
+ }
341
+
342
+ server$1 = PlaySocketServer;
343
+ return server$1;
344
+ }
345
+
346
+ var serverExports = requireServer();
347
+ var server = /*@__PURE__*/getDefaultExportFromCjs(serverExports);
348
+
349
+ module.exports = server;
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "playsocketjs",
3
+ "version": "1.0.0",
4
+ "description": "WebSocket wrapper for creating simple multiplayer systems with ease.",
5
+ "module": "dist/playsocket-client.js",
6
+ "exports": {
7
+ ".": {
8
+ "import": "./dist/playsocket-client.js"
9
+ },
10
+ "./server": {
11
+ "require": "./dist/playsocket-server.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "test": "echo \"Error: no test specified\" && exit 1",
19
+ "build": "rollup -c rollup.config.mjs"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/therealPaulPlay/PlaySocketJS.git"
24
+ },
25
+ "keywords": [
26
+ "WebSocket",
27
+ "data channel",
28
+ "Socket",
29
+ "PlayPeerJS",
30
+ "multiplayer",
31
+ "game"
32
+ ],
33
+ "author": "PaulPlay",
34
+ "license": "MIT",
35
+ "bugs": {
36
+ "url": "https://github.com/therealPaulPlay/PlaySocketJS/issues"
37
+ },
38
+ "homepage": "https://github.com/therealPaulPlay/PlaySocketJS#readme",
39
+ "devDependencies": {
40
+ "@rollup/plugin-commonjs": "^28.0.2",
41
+ "@rollup/plugin-node-resolve": "^16.0.0",
42
+ "rollup": "^4.31.0"
43
+ },
44
+ "peerDependencies": {
45
+ "ws": "^8.18.1"
46
+ }
47
+ }