playsocketjs 2.6.4 → 3.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 +38 -16
- package/dist/playsocket-client.js +120 -92
- package/dist/playsocket-server.js +213 -138
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ socket.onEvent('storageUpdated', storage => (reactiveVariable = storage)); // As
|
|
|
67
67
|
|
|
68
68
|
Interfacing with the synchronized storage (examples):
|
|
69
69
|
```javascript
|
|
70
|
-
const currentState = socket.
|
|
70
|
+
const currentState = socket.storage; // Synchronous, local access
|
|
71
71
|
socket.updateStorage('players', 'array-add-unique', { username: 'Player4', level: 2 }); // Special method to enable conflict-free additions for arrays
|
|
72
72
|
socket.updateStorage('latestPlayer', 'set', 'Player4'); // Regular synced storage update
|
|
73
73
|
```
|
|
@@ -92,20 +92,20 @@ new PlaySocket(id?: string, options: PlaySocketOptions)
|
|
|
92
92
|
|
|
93
93
|
| Option | Type | Default | Description |
|
|
94
94
|
|--------|------|---------|-------------|
|
|
95
|
-
| `endpoint` | string | `
|
|
96
|
-
| `customData` | object | `{}` | Arbitrary data to pass to the "clientRegistered" server event
|
|
95
|
+
| `endpoint` | string | `undefined` | WebSocket server endpoint (e.g., 'wss://example.com/socket') (required) |
|
|
96
|
+
| `customData` | object | `{}` | Arbitrary data to pass to the "clientRegistered" server event |
|
|
97
97
|
| `debug` | boolean | `false` | Set to true to enable extra logging |
|
|
98
98
|
|
|
99
99
|
### Methods
|
|
100
100
|
|
|
101
101
|
| Method | Parameters | Return type | Description |
|
|
102
102
|
|--------|------------|-------------|-------------|
|
|
103
|
-
| `init()` | - | `Promise<string>` | Initialize the WebSocket connection – Returns
|
|
104
|
-
| `createRoom()` | `initialStorage?: object, size?: number` | `Promise<string>` | Create a new room and become host – Returns
|
|
103
|
+
| `init()` | - | `Promise<string>` | Initialize the WebSocket connection – Returns a promise which resolves with the client's ID |
|
|
104
|
+
| `createRoom()` | `initialStorage?: object, size?: number` | `Promise<string>` | Create a new room and become host – Returns a promise which resolves with the room ID. The room participant maximum is 100 |
|
|
105
105
|
| `joinRoom()` | `roomId: string` | `Promise<void>` | Join an existing room |
|
|
106
106
|
| `destroy()` | - | `void` | Use this to leave a room and close the connection |
|
|
107
107
|
| `updateStorage()` | `key: string, type: 'set' \| 'array-add' \| 'array-add-unique' \| 'array-remove-matching' \| 'array-update-matching', value: any, updateValue?: any` | `void` | Update a key in the shared storage (max. 100 keys). Array operation types allow for conflict-free simultaneous array updates. For '-matching' operations, value becomes the value to match, and updateValue the replacement |
|
|
108
|
-
| `sendRequest()` | `name: string, data?: any` | `void` | Send requests to the server with optional custom data (handle
|
|
108
|
+
| `sendRequest()` | `name: string, data?: any` | `void` | Send requests to the server with optional custom data (handle them in the `requestReceived` server event) |
|
|
109
109
|
| `onEvent()` | `event: string, callback: Function` | `void` | Register an event callback |
|
|
110
110
|
|
|
111
111
|
### Event types
|
|
@@ -114,11 +114,12 @@ new PlaySocket(id?: string, options: PlaySocketOptions)
|
|
|
114
114
|
|-------|-------------------|-------------|
|
|
115
115
|
| `status` | `status: string` | Connection status updates |
|
|
116
116
|
| `error` | `error: string` | Error events |
|
|
117
|
+
| `moved` | `roomId: string` | Moved to different room |
|
|
117
118
|
| `instanceDestroyed` | - | Destruction event - triggered by manual .destroy() method invocation or by fatal errors and disconnects |
|
|
118
119
|
| `storageUpdated` | `storage: object` | Storage state changes |
|
|
119
120
|
| `hostMigrated` | `roomId: string` | Host changes |
|
|
120
|
-
| `
|
|
121
|
-
| `
|
|
121
|
+
| `clientJoined` | `clientId: string` | New client joined the room |
|
|
122
|
+
| `clientLeft` | `clientId: string, roomId?: string` | Client left the room |
|
|
122
123
|
|
|
123
124
|
### Properties (Read-only)
|
|
124
125
|
|
|
@@ -126,8 +127,8 @@ new PlaySocket(id?: string, options: PlaySocketOptions)
|
|
|
126
127
|
|----------|------|-------------|
|
|
127
128
|
| `id` | string | Client's unique identifier on the WebSocket server |
|
|
128
129
|
| `isHost` | boolean | If this user is currently assigned the host role |
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
130
|
+
| `participantCount` | number | Number of active client connections in room (with yourself) |
|
|
131
|
+
| `storage` | object | Retrieve storage object |
|
|
131
132
|
|
|
132
133
|
|
|
133
134
|
|
|
@@ -160,7 +161,7 @@ function shutdown() {
|
|
|
160
161
|
process.exit(0);
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
// Handle both SIGINT
|
|
164
|
+
// Handle both SIGINT and SIGTERM
|
|
164
165
|
process.on('SIGINT', shutdown);
|
|
165
166
|
process.on('SIGTERM', shutdown);
|
|
166
167
|
```
|
|
@@ -192,7 +193,7 @@ function shutdown() {
|
|
|
192
193
|
process.exit(0);
|
|
193
194
|
}
|
|
194
195
|
|
|
195
|
-
// Handle both SIGINT
|
|
196
|
+
// Handle both SIGINT and SIGTERM
|
|
196
197
|
process.on('SIGINT', shutdown);
|
|
197
198
|
process.on('SIGTERM', shutdown);
|
|
198
199
|
```
|
|
@@ -216,6 +217,26 @@ new PlaySocket(options: PlaySocketServerOptions)
|
|
|
216
217
|
| `server` | http.Server | - | Existing http server (optional) |
|
|
217
218
|
| `rateLimit` | number | 20 | Adjust the messages/second rate limit |
|
|
218
219
|
| `debug` | boolean | false | Set to true to enable extra logging |
|
|
220
|
+
| `verifyClient` | function | - | Optional callback to verify connections before WebSocket upgrade |
|
|
221
|
+
|
|
222
|
+
#### verifyClient callback
|
|
223
|
+
|
|
224
|
+
The `verifyClient` option allows you to implement custom connection verification logic, such as rate limiting, before the WebSocket handshake completes.
|
|
225
|
+
|
|
226
|
+
```javascript
|
|
227
|
+
const server = new PlaySocketServer({
|
|
228
|
+
server: httpServer,
|
|
229
|
+
path: '/socket',
|
|
230
|
+
verifyClient: (info, callback) => {
|
|
231
|
+
// info.req - the HTTP request object, info.origin - the Origin header value
|
|
232
|
+
const ip = info.req.headers['x-forwarded-for'] || info.req.socket.remoteAddress;
|
|
233
|
+
if (isRateLimited(ip)) return callback(false, 429, 'Too Many Requests');
|
|
234
|
+
callback(true);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The callback signature is `callback(verified, code?, message?)` where `code` and `message` are the optional HTTP response status for rejected connections.
|
|
219
240
|
|
|
220
241
|
### Methods
|
|
221
242
|
|
|
@@ -223,10 +244,11 @@ new PlaySocket(options: PlaySocketServerOptions)
|
|
|
223
244
|
|--------|------------|-------------|-------------|
|
|
224
245
|
| `stop()` | - | `void` | Closes all active client connections, the websocket server and the underlying http server if it's standalone |
|
|
225
246
|
| `kick()` | `clientId: string, reason?: string` | `void` | Kick a client by their clientID – this will close their connection and set an error message |
|
|
247
|
+
| `move()` | `clientId: string, roomId: string` | `void` | Move a client, that is already in a room, to a different room |
|
|
226
248
|
| `onEvent()` | `event: string, callback: Function` | `void` | Register a server-side event callback |
|
|
227
249
|
| `getRoomStorage()` | `roomId: string` | `object` | Get a snapshot of the current room storage |
|
|
228
250
|
| `updateRoomStorage()` | `roomId: string, key: string, type: 'set' \| 'array-add' \| 'array-add-unique' \| 'array-remove-matching' \| 'array-update-matching', value: any, updateValue?: any` | `void` | Update a key in the shared room storage from the server |
|
|
229
|
-
| `createRoom()` | `initialStorage?: object, size?: number, host?: string` | `object` | Create a room (returns object containing room ID and state)
|
|
251
|
+
| `createRoom()` | `initialStorage?: object, size?: number, host?: string` | `object` | Create a room (returns object containing room ID and state)|
|
|
230
252
|
| `destroyRoom()` | `roomId: string` | `void` | Destroy a room & kick all participants |
|
|
231
253
|
|
|
232
254
|
### Event types
|
|
@@ -235,11 +257,11 @@ new PlaySocket(options: PlaySocketServerOptions)
|
|
|
235
257
|
|-------|-------------------|-------------|--------------|
|
|
236
258
|
| `clientRegistered` | `clientId: string, customData: object` | Client registered with the server | - |
|
|
237
259
|
| `clientRegistrationRequested` | `clientId: string, customData: object` | Client requests to register | Return `false` or rejection reason `string` to block |
|
|
238
|
-
| `
|
|
260
|
+
| `clientLeft` | `clientId: string` | Client disconnected from the server | - |
|
|
239
261
|
| `clientJoinedRoom` | `clientId: string, roomId: string` | Client joined a room (clients can only leave by disconnecting) | - |
|
|
240
262
|
| `clientJoinRequested` | `clientId: string, roomId: string` | Client requests to join a room | Return `false` or rejection reason `string` to block |
|
|
241
263
|
| `roomCreated` | `roomId: string` | Client created a room | - |
|
|
242
|
-
| `roomDestroyed` | `roomId: string` | Room was destroyed (happens when all participants leave) | - |
|
|
264
|
+
| `roomDestroyed` | `roomId: string` | Room was destroyed (happens when all participants leave, unless room host is "server") | - |
|
|
243
265
|
| `roomCreationRequested` | `{clientId: string, initialStorage: object}` | Room creation requested by client | Return `object` to override initial storage, `false` to deny |
|
|
244
266
|
| `storageUpdated` | `{clientId: string, roomId: string, update: object, storage: object}` | Room storage property updated | - |
|
|
245
267
|
| `storageUpdateRequested` | `{clientId: string, roomId: string, update: object, storage: object}` | Room storage property update requested by client | Return `false` to block the update |
|
|
@@ -249,7 +271,7 @@ new PlaySocket(options: PlaySocketServerOptions)
|
|
|
249
271
|
|
|
250
272
|
| Property | Type | Description |
|
|
251
273
|
|----------|------|-------------|
|
|
252
|
-
| `
|
|
274
|
+
| `rooms` | object | Retrieve the rooms object |
|
|
253
275
|
|
|
254
276
|
# License
|
|
255
277
|
|
|
@@ -170,6 +170,8 @@ function utf8Decode(bytes, inputOffset, byteLength) {
|
|
|
170
170
|
* ExtData is used to handle Extension Types that are not registered to ExtensionCodec.
|
|
171
171
|
*/
|
|
172
172
|
class ExtData {
|
|
173
|
+
type;
|
|
174
|
+
data;
|
|
173
175
|
constructor(type, data) {
|
|
174
176
|
this.type = type;
|
|
175
177
|
this.data = data;
|
|
@@ -313,13 +315,18 @@ const timestampExtension = {
|
|
|
313
315
|
|
|
314
316
|
// ExtensionCodec to handle MessagePack extensions
|
|
315
317
|
class ExtensionCodec {
|
|
318
|
+
static defaultCodec = new ExtensionCodec();
|
|
319
|
+
// ensures ExtensionCodecType<X> matches ExtensionCodec<X>
|
|
320
|
+
// this will make type errors a lot more clear
|
|
321
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
322
|
+
__brand;
|
|
323
|
+
// built-in extensions
|
|
324
|
+
builtInEncoders = [];
|
|
325
|
+
builtInDecoders = [];
|
|
326
|
+
// custom extensions
|
|
327
|
+
encoders = [];
|
|
328
|
+
decoders = [];
|
|
316
329
|
constructor() {
|
|
317
|
-
// built-in extensions
|
|
318
|
-
this.builtInEncoders = [];
|
|
319
|
-
this.builtInDecoders = [];
|
|
320
|
-
// custom extensions
|
|
321
|
-
this.encoders = [];
|
|
322
|
-
this.decoders = [];
|
|
323
330
|
this.register(timestampExtension);
|
|
324
331
|
}
|
|
325
332
|
register({ type, encode, decode, }) {
|
|
@@ -375,7 +382,6 @@ class ExtensionCodec {
|
|
|
375
382
|
}
|
|
376
383
|
}
|
|
377
384
|
}
|
|
378
|
-
ExtensionCodec.defaultCodec = new ExtensionCodec();
|
|
379
385
|
|
|
380
386
|
function isArrayBufferLike(buffer) {
|
|
381
387
|
return (buffer instanceof ArrayBuffer || (typeof SharedArrayBuffer !== "undefined" && buffer instanceof SharedArrayBuffer));
|
|
@@ -399,8 +405,20 @@ function ensureUint8Array(buffer) {
|
|
|
399
405
|
const DEFAULT_MAX_DEPTH = 100;
|
|
400
406
|
const DEFAULT_INITIAL_BUFFER_SIZE = 2048;
|
|
401
407
|
class Encoder {
|
|
408
|
+
extensionCodec;
|
|
409
|
+
context;
|
|
410
|
+
useBigInt64;
|
|
411
|
+
maxDepth;
|
|
412
|
+
initialBufferSize;
|
|
413
|
+
sortKeys;
|
|
414
|
+
forceFloat32;
|
|
415
|
+
ignoreUndefined;
|
|
416
|
+
forceIntegerToFloat;
|
|
417
|
+
pos;
|
|
418
|
+
view;
|
|
419
|
+
bytes;
|
|
420
|
+
entered = false;
|
|
402
421
|
constructor(options) {
|
|
403
|
-
this.entered = false;
|
|
404
422
|
this.extensionCodec = options?.extensionCodec ?? ExtensionCodec.defaultCodec;
|
|
405
423
|
this.context = options?.context; // needs a type assertion because EncoderOptions has no context property when ContextType is undefined
|
|
406
424
|
this.useBigInt64 = options?.useBigInt64 ?? false;
|
|
@@ -892,9 +910,12 @@ function prettyByte(byte) {
|
|
|
892
910
|
const DEFAULT_MAX_KEY_LENGTH = 16;
|
|
893
911
|
const DEFAULT_MAX_LENGTH_PER_KEY = 16;
|
|
894
912
|
class CachedKeyDecoder {
|
|
913
|
+
hit = 0;
|
|
914
|
+
miss = 0;
|
|
915
|
+
caches;
|
|
916
|
+
maxKeyLength;
|
|
917
|
+
maxLengthPerKey;
|
|
895
918
|
constructor(maxKeyLength = DEFAULT_MAX_KEY_LENGTH, maxLengthPerKey = DEFAULT_MAX_LENGTH_PER_KEY) {
|
|
896
|
-
this.hit = 0;
|
|
897
|
-
this.miss = 0;
|
|
898
919
|
this.maxKeyLength = maxKeyLength;
|
|
899
920
|
this.maxLengthPerKey = maxLengthPerKey;
|
|
900
921
|
// avoid `new Array(N)`, which makes a sparse array,
|
|
@@ -957,10 +978,8 @@ const mapKeyConverter = (key) => {
|
|
|
957
978
|
throw new DecodeError("The type of key must be string or number but " + typeof key);
|
|
958
979
|
};
|
|
959
980
|
class StackPool {
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
this.stackHeadPosition = -1;
|
|
963
|
-
}
|
|
981
|
+
stack = [];
|
|
982
|
+
stackHeadPosition = -1;
|
|
964
983
|
get length() {
|
|
965
984
|
return this.stackHeadPosition + 1;
|
|
966
985
|
}
|
|
@@ -1039,14 +1058,25 @@ catch (e) {
|
|
|
1039
1058
|
const MORE_DATA = new RangeError("Insufficient data");
|
|
1040
1059
|
const sharedCachedKeyDecoder = new CachedKeyDecoder();
|
|
1041
1060
|
class Decoder {
|
|
1061
|
+
extensionCodec;
|
|
1062
|
+
context;
|
|
1063
|
+
useBigInt64;
|
|
1064
|
+
rawStrings;
|
|
1065
|
+
maxStrLength;
|
|
1066
|
+
maxBinLength;
|
|
1067
|
+
maxArrayLength;
|
|
1068
|
+
maxMapLength;
|
|
1069
|
+
maxExtLength;
|
|
1070
|
+
keyDecoder;
|
|
1071
|
+
mapKeyConverter;
|
|
1072
|
+
totalPos = 0;
|
|
1073
|
+
pos = 0;
|
|
1074
|
+
view = EMPTY_VIEW;
|
|
1075
|
+
bytes = EMPTY_BYTES;
|
|
1076
|
+
headByte = HEAD_BYTE_REQUIRED;
|
|
1077
|
+
stack = new StackPool();
|
|
1078
|
+
entered = false;
|
|
1042
1079
|
constructor(options) {
|
|
1043
|
-
this.totalPos = 0;
|
|
1044
|
-
this.pos = 0;
|
|
1045
|
-
this.view = EMPTY_VIEW;
|
|
1046
|
-
this.bytes = EMPTY_BYTES;
|
|
1047
|
-
this.headByte = HEAD_BYTE_REQUIRED;
|
|
1048
|
-
this.stack = new StackPool();
|
|
1049
|
-
this.entered = false;
|
|
1050
1080
|
this.extensionCodec = options?.extensionCodec ?? ExtensionCodec.defaultCodec;
|
|
1051
1081
|
this.context = options?.context; // needs a type assertion because EncoderOptions has no context property when ContextType is undefined
|
|
1052
1082
|
this.useBigInt64 = options?.useBigInt64 ?? false;
|
|
@@ -1669,7 +1699,7 @@ class Decoder {
|
|
|
1669
1699
|
* It decodes a single MessagePack object in a buffer.
|
|
1670
1700
|
*
|
|
1671
1701
|
* This is a synchronous decoding function.
|
|
1672
|
-
* See other variants for asynchronous decoding: {@link decodeAsync}, {@link
|
|
1702
|
+
* See other variants for asynchronous decoding: {@link decodeAsync}, {@link decodeMultiStream}, or {@link decodeArrayStream}.
|
|
1673
1703
|
*
|
|
1674
1704
|
* @throws {@link RangeError} if the buffer is incomplete, including the case where the buffer is empty.
|
|
1675
1705
|
* @throws {@link DecodeError} if the buffer contains invalid data.
|
|
@@ -1679,12 +1709,19 @@ function decode(buffer, options) {
|
|
|
1679
1709
|
return decoder.decode(buffer);
|
|
1680
1710
|
}
|
|
1681
1711
|
|
|
1682
|
-
|
|
1712
|
+
/**
|
|
1713
|
+
* Heartbeat ping interval in milliseconds
|
|
1714
|
+
* Also used as the minimum age for CRDT garbage collection - operations are
|
|
1715
|
+
* retained for at least one heartbeat cycle to ensure even slow clients can never
|
|
1716
|
+
* be desynced as a result of gc
|
|
1717
|
+
*/
|
|
1718
|
+
const HEARTBEAT_INTERVAL = 5000;
|
|
1683
1719
|
|
|
1684
1720
|
const CONSOLE_PREFIX = "PlaySocket CRDT manager: ";
|
|
1685
1721
|
|
|
1686
1722
|
/**
|
|
1687
1723
|
* CRDT Manager
|
|
1724
|
+
* Custom conflict-free replicated data type system with vector clocks
|
|
1688
1725
|
*/
|
|
1689
1726
|
class CRDTManager {
|
|
1690
1727
|
// Storage
|
|
@@ -1692,7 +1729,7 @@ class CRDTManager {
|
|
|
1692
1729
|
#keyOperations = new Map();
|
|
1693
1730
|
#vectorClock = new Map();
|
|
1694
1731
|
|
|
1695
|
-
#propertyStore = {}; // Current local values
|
|
1732
|
+
#propertyStore = {}; // Current local values as key/value store object
|
|
1696
1733
|
#lastPropertyStore = {}; // Last property store to compare against
|
|
1697
1734
|
|
|
1698
1735
|
// Local Garbage Collection
|
|
@@ -1835,7 +1872,6 @@ class CRDTManager {
|
|
|
1835
1872
|
*/
|
|
1836
1873
|
#checkGarbageCollection() {
|
|
1837
1874
|
const MIN_GC_DELAY = 1000; // Minimum 1s delay between garbage collection runs
|
|
1838
|
-
const MIN_AGE_FOR_GC = 5000; // Garbage collect ops that are older than 5s
|
|
1839
1875
|
|
|
1840
1876
|
try {
|
|
1841
1877
|
if (Date.now() - this.#lastGCCheck < MIN_GC_DELAY) return;
|
|
@@ -1847,7 +1883,7 @@ class CRDTManager {
|
|
|
1847
1883
|
let retainCount = operations.length;
|
|
1848
1884
|
operations.forEach((op, index) => {
|
|
1849
1885
|
// Count how many ops, from last to latest, are older than the min-age in a row
|
|
1850
|
-
if (op.uuid && this.#opUuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuidTimestamp.get(op.uuid)) >
|
|
1886
|
+
if (op.uuid && this.#opUuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuidTimestamp.get(op.uuid)) > HEARTBEAT_INTERVAL) {
|
|
1851
1887
|
const removeCount = index + 1;
|
|
1852
1888
|
retainCount = operations.length - removeCount;
|
|
1853
1889
|
this.#opUuidTimestamp.delete(op.uuid); // Remove from timestamps if set to be deleted
|
|
@@ -2041,7 +2077,7 @@ class CRDTManager {
|
|
|
2041
2077
|
* Get property store
|
|
2042
2078
|
* @returns {Record<string, any>} - Object with key value pairs
|
|
2043
2079
|
*/
|
|
2044
|
-
get
|
|
2080
|
+
get propertyStore() {
|
|
2045
2081
|
try {
|
|
2046
2082
|
return structuredClone(this.#propertyStore); // Return deep clone
|
|
2047
2083
|
} catch {
|
|
@@ -2070,7 +2106,7 @@ class CRDTManager {
|
|
|
2070
2106
|
* Export state (can be imported using importState, converts the maps to arrays for serialization)
|
|
2071
2107
|
* @returns {object} - State
|
|
2072
2108
|
*/
|
|
2073
|
-
get
|
|
2109
|
+
get state() {
|
|
2074
2110
|
return {
|
|
2075
2111
|
keyOperations: [...this.#keyOperations.entries()],
|
|
2076
2112
|
vectorClock: [...this.#vectorClock.entries()]
|
|
@@ -2098,7 +2134,7 @@ class PlaySocket {
|
|
|
2098
2134
|
// Room properties
|
|
2099
2135
|
#roomHost;
|
|
2100
2136
|
#inRoom; // If the client is currently in a room or not
|
|
2101
|
-
#
|
|
2137
|
+
#participantCount = 0;
|
|
2102
2138
|
#crdtManager;
|
|
2103
2139
|
#roomVersion = 0; // Update version (used to compare local vs. remote state to detect package loss)
|
|
2104
2140
|
|
|
@@ -2154,7 +2190,7 @@ class PlaySocket {
|
|
|
2154
2190
|
* @param {Function} callback - Callback function
|
|
2155
2191
|
*/
|
|
2156
2192
|
onEvent(event, callback) {
|
|
2157
|
-
const validEvents = ["status", "error", "instanceDestroyed", "storageUpdated", "hostMigrated", "
|
|
2193
|
+
const validEvents = ["status", "error", "moved", "instanceDestroyed", "storageUpdated", "hostMigrated", "clientJoined", "clientLeft"];
|
|
2158
2194
|
if (!validEvents.includes(event)) {
|
|
2159
2195
|
console.warn(WARNING_PREFIX + `Invalid event type "${event}".`);
|
|
2160
2196
|
return;
|
|
@@ -2245,64 +2281,59 @@ class PlaySocket {
|
|
|
2245
2281
|
|
|
2246
2282
|
switch (message.type) {
|
|
2247
2283
|
case 'registration_failed':
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
this.#triggerEvent("error", "Failed to register: " + message.reason);
|
|
2251
|
-
}
|
|
2284
|
+
this.#triggerEvent("error", "Failed to register: " + message.reason);
|
|
2285
|
+
if (this.#pendingRegistration) this.#pendingRegistration.reject(new Error("Failed to register: " + message.reason));
|
|
2252
2286
|
break;
|
|
2253
2287
|
|
|
2254
2288
|
case 'registered':
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
this.#triggerEvent("status", "Connected to server.");
|
|
2260
|
-
}
|
|
2289
|
+
this.#sessionToken = message.sessionToken;
|
|
2290
|
+
this.#initialized = true;
|
|
2291
|
+
if (this.#pendingRegistration) this.#pendingRegistration.resolve(message.id);
|
|
2292
|
+
this.#triggerEvent("status", "Connected to server.");
|
|
2261
2293
|
break;
|
|
2262
2294
|
|
|
2263
2295
|
case 'join_accepted':
|
|
2264
2296
|
// Connected to room
|
|
2297
|
+
if (this.#debug) console.log(LOG_PREFIX + "State received for join:", message.state);
|
|
2298
|
+
this.#inRoom = true;
|
|
2299
|
+
this.#crdtManager.importState(message.state);
|
|
2300
|
+
this.#participantCount = message.participantCount;
|
|
2301
|
+
this.#roomHost = message.host;
|
|
2302
|
+
this.#roomVersion = message.version;
|
|
2303
|
+
this.#triggerEvent("storageUpdated", this.storage);
|
|
2265
2304
|
if (this.#pendingJoin) {
|
|
2266
|
-
|
|
2267
|
-
this.#inRoom = true;
|
|
2268
|
-
this.#crdtManager.importState(message.state);
|
|
2269
|
-
this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
|
|
2270
|
-
this.#roomHost = message.host;
|
|
2271
|
-
this.#roomVersion = message.version;
|
|
2272
|
-
this.#triggerEvent("storageUpdated", this.getStorage);
|
|
2273
|
-
this.#triggerEvent("status", `Connected to room.`);
|
|
2305
|
+
this.#triggerEvent("status", "Connected to room.");
|
|
2274
2306
|
this.#pendingJoin.resolve();
|
|
2307
|
+
} else {
|
|
2308
|
+
this.#triggerEvent("status", "Moved to room.");
|
|
2309
|
+
this.#triggerEvent("moved", message.roomId);
|
|
2275
2310
|
}
|
|
2276
2311
|
break;
|
|
2277
2312
|
|
|
2278
2313
|
case 'join_rejected':
|
|
2279
2314
|
// Connection to room failed
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
this.#pendingJoin.reject(new Error("Failed to join room: " + message.reason));
|
|
2283
|
-
}
|
|
2315
|
+
this.#triggerEvent("status", "Failed to join room: " + message.reason);
|
|
2316
|
+
if (this.#pendingJoin) this.#pendingJoin.reject(new Error("Failed to join room: " + message.reason));
|
|
2284
2317
|
break;
|
|
2285
2318
|
|
|
2286
2319
|
case 'reconnected':
|
|
2287
2320
|
// Successfully reconnected
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
if (message.roomData)
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
return this.destroy();
|
|
2302
|
-
}
|
|
2303
|
-
this.#triggerEvent("status", "Reconnected.");
|
|
2304
|
-
this.#pendingReconnect.resolve();
|
|
2321
|
+
this.#isReconnecting = false;
|
|
2322
|
+
this.#reconnectCount = 0;
|
|
2323
|
+
if (message.roomData) {
|
|
2324
|
+
if (this.#debug) console.log(LOG_PREFIX + "State received for reconnect:", message.roomData.state);
|
|
2325
|
+
this.#crdtManager.importState(message.roomData.state);
|
|
2326
|
+
this.#roomVersion = message.roomData.version;
|
|
2327
|
+
this.#participantCount = message.roomData.participantCount;
|
|
2328
|
+
this.#setHost(message.roomData.host); // Set host before in case there are .isHost checks in the storageUpdate fallback
|
|
2329
|
+
this.#triggerEvent("storageUpdated", this.storage);
|
|
2330
|
+
} else if (this.#inRoom) {
|
|
2331
|
+
// If no room data received, but client thinks they were in a room...
|
|
2332
|
+
this.#triggerEvent("error", "Reconnected, but room no longer exists.");
|
|
2333
|
+
return this.destroy();
|
|
2305
2334
|
}
|
|
2335
|
+
this.#triggerEvent("status", "Reconnected.");
|
|
2336
|
+
if (this.#pendingReconnect) this.#pendingReconnect.resolve();
|
|
2306
2337
|
break;
|
|
2307
2338
|
|
|
2308
2339
|
case 'reconnection_failed':
|
|
@@ -2310,27 +2341,24 @@ class PlaySocket {
|
|
|
2310
2341
|
break;
|
|
2311
2342
|
|
|
2312
2343
|
case 'room_created':
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
}
|
|
2344
|
+
this.#inRoom = true;
|
|
2345
|
+
this.#participantCount = message.participantCount;
|
|
2346
|
+
this.#triggerEvent("status", `Room created.`);
|
|
2347
|
+
this.#crdtManager.importState(message.state);
|
|
2348
|
+
this.#triggerEvent("storageUpdated", this.storage);
|
|
2349
|
+
if (this.#pendingCreate) this.#pendingCreate.resolve(message.roomId);
|
|
2320
2350
|
break;
|
|
2321
2351
|
|
|
2322
2352
|
case 'room_creation_failed':
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
this.#pendingCreate.reject(new Error("Failed to create room: " + message.reason));
|
|
2326
|
-
}
|
|
2353
|
+
this.#triggerEvent("error", "Failed to create room: " + message.reason);
|
|
2354
|
+
if (this.#pendingCreate) this.#pendingCreate.reject(new Error("Failed to create room: " + message.reason));
|
|
2327
2355
|
break;
|
|
2328
2356
|
|
|
2329
2357
|
case 'property_updated':
|
|
2330
2358
|
this.#roomVersion++; // Increment room version
|
|
2331
2359
|
if (this.#debug) console.log(LOG_PREFIX + "Property update received:", message.update);
|
|
2332
2360
|
this.#crdtManager.importPropertyUpdate(message.update);
|
|
2333
|
-
if (this.#crdtManager.didPropertiesChange) this.#triggerEvent("storageUpdated", this.
|
|
2361
|
+
if (this.#crdtManager.didPropertiesChange) this.#triggerEvent("storageUpdated", this.storage);
|
|
2334
2362
|
if (this.#roomVersion != message.version && this.#initialized && this.#socket?.readyState === WebSocket.OPEN) {
|
|
2335
2363
|
this.#triggerEvent("error", "Detected skipped update – forcing reconnect.");
|
|
2336
2364
|
this.#socket?.close();
|
|
@@ -2340,7 +2368,7 @@ class PlaySocket {
|
|
|
2340
2368
|
case 'property_update_rejected':
|
|
2341
2369
|
this.#triggerEvent("error", "Property update rejected. Re-syncing state.");
|
|
2342
2370
|
this.#crdtManager.importState(message.state);
|
|
2343
|
-
this.#triggerEvent("storageUpdated", this.
|
|
2371
|
+
this.#triggerEvent("storageUpdated", this.storage);
|
|
2344
2372
|
break;
|
|
2345
2373
|
|
|
2346
2374
|
case 'kicked':
|
|
@@ -2352,15 +2380,15 @@ class PlaySocket {
|
|
|
2352
2380
|
this.#setHost(message.newHost);
|
|
2353
2381
|
break;
|
|
2354
2382
|
|
|
2355
|
-
case '
|
|
2356
|
-
this.#
|
|
2357
|
-
this.#triggerEvent("
|
|
2383
|
+
case 'client_left':
|
|
2384
|
+
this.#participantCount = message.participantCount;
|
|
2385
|
+
this.#triggerEvent("clientLeft", message.client);
|
|
2358
2386
|
this.#triggerEvent("status", `Client ${message.client} disconnected.`);
|
|
2359
2387
|
break;
|
|
2360
2388
|
|
|
2361
|
-
case '
|
|
2362
|
-
this.#
|
|
2363
|
-
this.#triggerEvent("
|
|
2389
|
+
case 'client_joined':
|
|
2390
|
+
this.#participantCount = message.participantCount;
|
|
2391
|
+
this.#triggerEvent("clientJoined", message.client);
|
|
2364
2392
|
this.#triggerEvent("status", `Client ${message.client} connected.`);
|
|
2365
2393
|
break;
|
|
2366
2394
|
}
|
|
@@ -2523,7 +2551,7 @@ class PlaySocket {
|
|
|
2523
2551
|
type: 'update_property',
|
|
2524
2552
|
update: propUpdate
|
|
2525
2553
|
});
|
|
2526
|
-
if (this.#crdtManager.didPropertiesChange) this.#triggerEvent("storageUpdated", this.
|
|
2554
|
+
if (this.#crdtManager.didPropertiesChange) this.#triggerEvent("storageUpdated", this.storage); // Always trigger callback AFTER send in case msgs are sent in the callback (which would break the order)
|
|
2527
2555
|
}
|
|
2528
2556
|
|
|
2529
2557
|
/**
|
|
@@ -2563,7 +2591,7 @@ class PlaySocket {
|
|
|
2563
2591
|
clearTimeout(this.#reconnectTimeout);
|
|
2564
2592
|
this.#roomHost = null;
|
|
2565
2593
|
this.#inRoom = false;
|
|
2566
|
-
this.#
|
|
2594
|
+
this.#participantCount = 0;
|
|
2567
2595
|
this.#isReconnecting = false;
|
|
2568
2596
|
this.#reconnectCount = 0;
|
|
2569
2597
|
this.#roomVersion = 0;
|
|
@@ -2577,8 +2605,8 @@ class PlaySocket {
|
|
|
2577
2605
|
}
|
|
2578
2606
|
|
|
2579
2607
|
// Public getters
|
|
2580
|
-
get
|
|
2581
|
-
get
|
|
2608
|
+
get participantCount() { return this.#participantCount; }
|
|
2609
|
+
get storage() { return this.#crdtManager.propertyStore; }
|
|
2582
2610
|
get isHost() { return this.#id == this.#roomHost; }
|
|
2583
2611
|
get id() { return this.#id; }
|
|
2584
2612
|
}
|
|
@@ -173,6 +173,8 @@ function utf8Decode(bytes, inputOffset, byteLength) {
|
|
|
173
173
|
* ExtData is used to handle Extension Types that are not registered to ExtensionCodec.
|
|
174
174
|
*/
|
|
175
175
|
class ExtData {
|
|
176
|
+
type;
|
|
177
|
+
data;
|
|
176
178
|
constructor(type, data) {
|
|
177
179
|
this.type = type;
|
|
178
180
|
this.data = data;
|
|
@@ -316,13 +318,18 @@ const timestampExtension = {
|
|
|
316
318
|
|
|
317
319
|
// ExtensionCodec to handle MessagePack extensions
|
|
318
320
|
class ExtensionCodec {
|
|
321
|
+
static defaultCodec = new ExtensionCodec();
|
|
322
|
+
// ensures ExtensionCodecType<X> matches ExtensionCodec<X>
|
|
323
|
+
// this will make type errors a lot more clear
|
|
324
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
325
|
+
__brand;
|
|
326
|
+
// built-in extensions
|
|
327
|
+
builtInEncoders = [];
|
|
328
|
+
builtInDecoders = [];
|
|
329
|
+
// custom extensions
|
|
330
|
+
encoders = [];
|
|
331
|
+
decoders = [];
|
|
319
332
|
constructor() {
|
|
320
|
-
// built-in extensions
|
|
321
|
-
this.builtInEncoders = [];
|
|
322
|
-
this.builtInDecoders = [];
|
|
323
|
-
// custom extensions
|
|
324
|
-
this.encoders = [];
|
|
325
|
-
this.decoders = [];
|
|
326
333
|
this.register(timestampExtension);
|
|
327
334
|
}
|
|
328
335
|
register({ type, encode, decode, }) {
|
|
@@ -378,7 +385,6 @@ class ExtensionCodec {
|
|
|
378
385
|
}
|
|
379
386
|
}
|
|
380
387
|
}
|
|
381
|
-
ExtensionCodec.defaultCodec = new ExtensionCodec();
|
|
382
388
|
|
|
383
389
|
function isArrayBufferLike(buffer) {
|
|
384
390
|
return (buffer instanceof ArrayBuffer || (typeof SharedArrayBuffer !== "undefined" && buffer instanceof SharedArrayBuffer));
|
|
@@ -402,8 +408,20 @@ function ensureUint8Array(buffer) {
|
|
|
402
408
|
const DEFAULT_MAX_DEPTH = 100;
|
|
403
409
|
const DEFAULT_INITIAL_BUFFER_SIZE = 2048;
|
|
404
410
|
class Encoder {
|
|
411
|
+
extensionCodec;
|
|
412
|
+
context;
|
|
413
|
+
useBigInt64;
|
|
414
|
+
maxDepth;
|
|
415
|
+
initialBufferSize;
|
|
416
|
+
sortKeys;
|
|
417
|
+
forceFloat32;
|
|
418
|
+
ignoreUndefined;
|
|
419
|
+
forceIntegerToFloat;
|
|
420
|
+
pos;
|
|
421
|
+
view;
|
|
422
|
+
bytes;
|
|
423
|
+
entered = false;
|
|
405
424
|
constructor(options) {
|
|
406
|
-
this.entered = false;
|
|
407
425
|
this.extensionCodec = options?.extensionCodec ?? ExtensionCodec.defaultCodec;
|
|
408
426
|
this.context = options?.context; // needs a type assertion because EncoderOptions has no context property when ContextType is undefined
|
|
409
427
|
this.useBigInt64 = options?.useBigInt64 ?? false;
|
|
@@ -895,9 +913,12 @@ function prettyByte(byte) {
|
|
|
895
913
|
const DEFAULT_MAX_KEY_LENGTH = 16;
|
|
896
914
|
const DEFAULT_MAX_LENGTH_PER_KEY = 16;
|
|
897
915
|
class CachedKeyDecoder {
|
|
916
|
+
hit = 0;
|
|
917
|
+
miss = 0;
|
|
918
|
+
caches;
|
|
919
|
+
maxKeyLength;
|
|
920
|
+
maxLengthPerKey;
|
|
898
921
|
constructor(maxKeyLength = DEFAULT_MAX_KEY_LENGTH, maxLengthPerKey = DEFAULT_MAX_LENGTH_PER_KEY) {
|
|
899
|
-
this.hit = 0;
|
|
900
|
-
this.miss = 0;
|
|
901
922
|
this.maxKeyLength = maxKeyLength;
|
|
902
923
|
this.maxLengthPerKey = maxLengthPerKey;
|
|
903
924
|
// avoid `new Array(N)`, which makes a sparse array,
|
|
@@ -960,10 +981,8 @@ const mapKeyConverter = (key) => {
|
|
|
960
981
|
throw new DecodeError("The type of key must be string or number but " + typeof key);
|
|
961
982
|
};
|
|
962
983
|
class StackPool {
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
this.stackHeadPosition = -1;
|
|
966
|
-
}
|
|
984
|
+
stack = [];
|
|
985
|
+
stackHeadPosition = -1;
|
|
967
986
|
get length() {
|
|
968
987
|
return this.stackHeadPosition + 1;
|
|
969
988
|
}
|
|
@@ -1042,14 +1061,25 @@ catch (e) {
|
|
|
1042
1061
|
const MORE_DATA = new RangeError("Insufficient data");
|
|
1043
1062
|
const sharedCachedKeyDecoder = new CachedKeyDecoder();
|
|
1044
1063
|
class Decoder {
|
|
1064
|
+
extensionCodec;
|
|
1065
|
+
context;
|
|
1066
|
+
useBigInt64;
|
|
1067
|
+
rawStrings;
|
|
1068
|
+
maxStrLength;
|
|
1069
|
+
maxBinLength;
|
|
1070
|
+
maxArrayLength;
|
|
1071
|
+
maxMapLength;
|
|
1072
|
+
maxExtLength;
|
|
1073
|
+
keyDecoder;
|
|
1074
|
+
mapKeyConverter;
|
|
1075
|
+
totalPos = 0;
|
|
1076
|
+
pos = 0;
|
|
1077
|
+
view = EMPTY_VIEW;
|
|
1078
|
+
bytes = EMPTY_BYTES;
|
|
1079
|
+
headByte = HEAD_BYTE_REQUIRED;
|
|
1080
|
+
stack = new StackPool();
|
|
1081
|
+
entered = false;
|
|
1045
1082
|
constructor(options) {
|
|
1046
|
-
this.totalPos = 0;
|
|
1047
|
-
this.pos = 0;
|
|
1048
|
-
this.view = EMPTY_VIEW;
|
|
1049
|
-
this.bytes = EMPTY_BYTES;
|
|
1050
|
-
this.headByte = HEAD_BYTE_REQUIRED;
|
|
1051
|
-
this.stack = new StackPool();
|
|
1052
|
-
this.entered = false;
|
|
1053
1083
|
this.extensionCodec = options?.extensionCodec ?? ExtensionCodec.defaultCodec;
|
|
1054
1084
|
this.context = options?.context; // needs a type assertion because EncoderOptions has no context property when ContextType is undefined
|
|
1055
1085
|
this.useBigInt64 = options?.useBigInt64 ?? false;
|
|
@@ -1672,7 +1702,7 @@ class Decoder {
|
|
|
1672
1702
|
* It decodes a single MessagePack object in a buffer.
|
|
1673
1703
|
*
|
|
1674
1704
|
* This is a synchronous decoding function.
|
|
1675
|
-
* See other variants for asynchronous decoding: {@link decodeAsync}, {@link
|
|
1705
|
+
* See other variants for asynchronous decoding: {@link decodeAsync}, {@link decodeMultiStream}, or {@link decodeArrayStream}.
|
|
1676
1706
|
*
|
|
1677
1707
|
* @throws {@link RangeError} if the buffer is incomplete, including the case where the buffer is empty.
|
|
1678
1708
|
* @throws {@link DecodeError} if the buffer contains invalid data.
|
|
@@ -1682,12 +1712,19 @@ function decode(buffer, options) {
|
|
|
1682
1712
|
return decoder.decode(buffer);
|
|
1683
1713
|
}
|
|
1684
1714
|
|
|
1685
|
-
|
|
1715
|
+
/**
|
|
1716
|
+
* Heartbeat ping interval in milliseconds
|
|
1717
|
+
* Also used as the minimum age for CRDT garbage collection - operations are
|
|
1718
|
+
* retained for at least one heartbeat cycle to ensure even slow clients can never
|
|
1719
|
+
* be desynced as a result of gc
|
|
1720
|
+
*/
|
|
1721
|
+
const HEARTBEAT_INTERVAL = 5000;
|
|
1686
1722
|
|
|
1687
1723
|
const CONSOLE_PREFIX = "PlaySocket CRDT manager: ";
|
|
1688
1724
|
|
|
1689
1725
|
/**
|
|
1690
1726
|
* CRDT Manager
|
|
1727
|
+
* Custom conflict-free replicated data type system with vector clocks
|
|
1691
1728
|
*/
|
|
1692
1729
|
class CRDTManager {
|
|
1693
1730
|
// Storage
|
|
@@ -1695,7 +1732,7 @@ class CRDTManager {
|
|
|
1695
1732
|
#keyOperations = new Map();
|
|
1696
1733
|
#vectorClock = new Map();
|
|
1697
1734
|
|
|
1698
|
-
#propertyStore = {}; // Current local values
|
|
1735
|
+
#propertyStore = {}; // Current local values as key/value store object
|
|
1699
1736
|
#lastPropertyStore = {}; // Last property store to compare against
|
|
1700
1737
|
|
|
1701
1738
|
// Local Garbage Collection
|
|
@@ -1838,7 +1875,6 @@ class CRDTManager {
|
|
|
1838
1875
|
*/
|
|
1839
1876
|
#checkGarbageCollection() {
|
|
1840
1877
|
const MIN_GC_DELAY = 1000; // Minimum 1s delay between garbage collection runs
|
|
1841
|
-
const MIN_AGE_FOR_GC = 5000; // Garbage collect ops that are older than 5s
|
|
1842
1878
|
|
|
1843
1879
|
try {
|
|
1844
1880
|
if (Date.now() - this.#lastGCCheck < MIN_GC_DELAY) return;
|
|
@@ -1850,7 +1886,7 @@ class CRDTManager {
|
|
|
1850
1886
|
let retainCount = operations.length;
|
|
1851
1887
|
operations.forEach((op, index) => {
|
|
1852
1888
|
// Count how many ops, from last to latest, are older than the min-age in a row
|
|
1853
|
-
if (op.uuid && this.#opUuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuidTimestamp.get(op.uuid)) >
|
|
1889
|
+
if (op.uuid && this.#opUuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuidTimestamp.get(op.uuid)) > HEARTBEAT_INTERVAL) {
|
|
1854
1890
|
const removeCount = index + 1;
|
|
1855
1891
|
retainCount = operations.length - removeCount;
|
|
1856
1892
|
this.#opUuidTimestamp.delete(op.uuid); // Remove from timestamps if set to be deleted
|
|
@@ -2044,7 +2080,7 @@ class CRDTManager {
|
|
|
2044
2080
|
* Get property store
|
|
2045
2081
|
* @returns {Record<string, any>} - Object with key value pairs
|
|
2046
2082
|
*/
|
|
2047
|
-
get
|
|
2083
|
+
get propertyStore() {
|
|
2048
2084
|
try {
|
|
2049
2085
|
return structuredClone(this.#propertyStore); // Return deep clone
|
|
2050
2086
|
} catch {
|
|
@@ -2073,7 +2109,7 @@ class CRDTManager {
|
|
|
2073
2109
|
* Export state (can be imported using importState, converts the maps to arrays for serialization)
|
|
2074
2110
|
* @returns {object} - State
|
|
2075
2111
|
*/
|
|
2076
|
-
get
|
|
2112
|
+
get state() {
|
|
2077
2113
|
return {
|
|
2078
2114
|
keyOperations: [...this.#keyOperations.entries()],
|
|
2079
2115
|
vectorClock: [...this.#vectorClock.entries()]
|
|
@@ -2084,6 +2120,7 @@ class CRDTManager {
|
|
|
2084
2120
|
/* eslint-disable jsdoc/require-returns */
|
|
2085
2121
|
|
|
2086
2122
|
const MAX_ROOM_SIZE = 500;
|
|
2123
|
+
const RECONNECT_GRACE_PERIOD = 5000; // Exported for use in tests
|
|
2087
2124
|
|
|
2088
2125
|
/**
|
|
2089
2126
|
* @typedef {import('node:http').Server} HttpServer
|
|
@@ -2119,9 +2156,10 @@ class PlaySocketServer {
|
|
|
2119
2156
|
* @param {string} [options.path='/'] - WebSocket endpoint path
|
|
2120
2157
|
* @param {boolean} [options.debug=false] - Enable debug logging
|
|
2121
2158
|
* @param {number} [options.rateLimit=20] - Maximum number of operations per second per client
|
|
2159
|
+
* @param {Function} [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
|
|
2122
2160
|
*/
|
|
2123
2161
|
constructor(options = {}) {
|
|
2124
|
-
const { server, port = 3000, path = '/', debug = false, rateLimit = 20 } = options;
|
|
2162
|
+
const { server, port = 3000, path = '/', debug = false, rateLimit = 20, verifyClient } = options;
|
|
2125
2163
|
|
|
2126
2164
|
if (debug) this.#debug = true; // Enable extra logging
|
|
2127
2165
|
|
|
@@ -2141,7 +2179,8 @@ class PlaySocketServer {
|
|
|
2141
2179
|
// Create WebSocket server
|
|
2142
2180
|
this.#wss = new WebSocketServer({
|
|
2143
2181
|
server: this.#server,
|
|
2144
|
-
path
|
|
2182
|
+
path,
|
|
2183
|
+
...(verifyClient && { verifyClient })
|
|
2145
2184
|
});
|
|
2146
2185
|
|
|
2147
2186
|
// Set up ws event handlers
|
|
@@ -2168,7 +2207,7 @@ class PlaySocketServer {
|
|
|
2168
2207
|
ws.isAlive = false;
|
|
2169
2208
|
ws.ping();
|
|
2170
2209
|
});
|
|
2171
|
-
},
|
|
2210
|
+
}, HEARTBEAT_INTERVAL);
|
|
2172
2211
|
}
|
|
2173
2212
|
|
|
2174
2213
|
/**
|
|
@@ -2260,9 +2299,9 @@ class PlaySocketServer {
|
|
|
2260
2299
|
// If the room has no host (they were the only one and disconnected), restore them as host
|
|
2261
2300
|
if (formerRoom.host === null) formerRoom.host = data.id;
|
|
2262
2301
|
|
|
2263
|
-
if (this.#debug) console.log(`State sent for reconnection for room ${formerRoomId}:`, formerRoom.crdtManager.
|
|
2302
|
+
if (this.#debug) console.log(`State sent for reconnection for room ${formerRoomId}:`, formerRoom.crdtManager.state);
|
|
2264
2303
|
roomData = {
|
|
2265
|
-
state: formerRoom.crdtManager.
|
|
2304
|
+
state: formerRoom.crdtManager.state,
|
|
2266
2305
|
participantCount: formerRoom.participants.length,
|
|
2267
2306
|
host: formerRoom.host,
|
|
2268
2307
|
version: this.#roomVersions.get(formerRoomId)
|
|
@@ -2304,7 +2343,7 @@ class PlaySocketServer {
|
|
|
2304
2343
|
|
|
2305
2344
|
this.#rooms[newRoom.id].participants.push(ws.clientId); // Add client to the room
|
|
2306
2345
|
this.#clientRooms.set(ws.clientId, newRoom.id); // Add client to the client-room map
|
|
2307
|
-
ws.send(encode({ type: 'room_created', state: newRoom.state, roomId: newRoom.id }), { binary: true });
|
|
2346
|
+
ws.send(encode({ type: 'room_created', state: newRoom.state, roomId: newRoom.id, participantCount: this.#rooms[newRoom.id].participants.length }), { binary: true });
|
|
2308
2347
|
} catch (error) {
|
|
2309
2348
|
console.error("PlaySocket error creating room:", error);
|
|
2310
2349
|
ws.send(encode({ type: 'room_creation_failed', reason: error.message }), { binary: true });
|
|
@@ -2315,7 +2354,6 @@ class PlaySocketServer {
|
|
|
2315
2354
|
case 'join_room': {
|
|
2316
2355
|
if (!data.roomId || !ws.clientId) return;
|
|
2317
2356
|
const roomId = data.roomId;
|
|
2318
|
-
const room = this.#rooms[roomId];
|
|
2319
2357
|
|
|
2320
2358
|
const rejectJoin = (reason) => {
|
|
2321
2359
|
ws.send(encode({ type: 'join_rejected', reason }), { binary: true });
|
|
@@ -2325,47 +2363,12 @@ class PlaySocketServer {
|
|
|
2325
2363
|
const joinAllowed = await this.#triggerEvent("clientJoinRequested", ws.clientId, roomId);
|
|
2326
2364
|
if (joinAllowed === false || typeof joinAllowed === 'string') return rejectJoin(typeof joinAllowed === 'string' ? joinAllowed : 'Denied.');
|
|
2327
2365
|
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
room.participants.push(ws.clientId);
|
|
2333
|
-
this.#clientRooms.set(ws.clientId, roomId);
|
|
2334
|
-
|
|
2335
|
-
// Notify joiner with initial storage
|
|
2336
|
-
if (this.#debug) console.log("Room state sent for join:", room.crdtManager.getState);
|
|
2337
|
-
ws.send(encode({
|
|
2338
|
-
type: 'join_accepted',
|
|
2339
|
-
state: room.crdtManager.getState,
|
|
2340
|
-
participantCount: room.participants.length,
|
|
2341
|
-
host: room.host,
|
|
2342
|
-
version: this.#roomVersions.get(roomId)
|
|
2343
|
-
}), { binary: true });
|
|
2344
|
-
|
|
2345
|
-
// If the room has no host (previous host disconnected and was the only participant), make joiner the host
|
|
2346
|
-
// Since the room had to be empty, only the joiner needs to be notified
|
|
2347
|
-
const becameHost = room.host === null;
|
|
2348
|
-
if (becameHost) {
|
|
2349
|
-
room.host = ws.clientId;
|
|
2350
|
-
ws.send(encode({
|
|
2351
|
-
type: 'host_migrated',
|
|
2352
|
-
newHost: room.host,
|
|
2353
|
-
}), { binary: true });
|
|
2366
|
+
try {
|
|
2367
|
+
this.#joinRoom(ws.clientId, roomId);
|
|
2368
|
+
} catch (error) {
|
|
2369
|
+
rejectJoin(error.message);
|
|
2354
2370
|
}
|
|
2355
2371
|
|
|
2356
|
-
// Notify existing participants
|
|
2357
|
-
room.participants.forEach(p => {
|
|
2358
|
-
if (p === ws.clientId) return;
|
|
2359
|
-
const client = this.#clients.get(p);
|
|
2360
|
-
if (client) {
|
|
2361
|
-
client.send(encode({
|
|
2362
|
-
type: 'client_connected',
|
|
2363
|
-
client: ws.clientId,
|
|
2364
|
-
participantCount: room.participants.length
|
|
2365
|
-
}), { binary: true });
|
|
2366
|
-
}
|
|
2367
|
-
});
|
|
2368
|
-
this.#triggerEvent("clientJoinedRoom", ws.clientId, roomId);
|
|
2369
2372
|
break;
|
|
2370
2373
|
}
|
|
2371
2374
|
|
|
@@ -2379,7 +2382,7 @@ class PlaySocketServer {
|
|
|
2379
2382
|
if (updateAllowed === false) {
|
|
2380
2383
|
ws.send(encode({
|
|
2381
2384
|
type: 'property_update_rejected',
|
|
2382
|
-
state: room.crdtManager.
|
|
2385
|
+
state: room.crdtManager.state
|
|
2383
2386
|
}), { binary: true });
|
|
2384
2387
|
return;
|
|
2385
2388
|
}
|
|
@@ -2434,29 +2437,28 @@ class PlaySocketServer {
|
|
|
2434
2437
|
}
|
|
2435
2438
|
|
|
2436
2439
|
/**
|
|
2437
|
-
*
|
|
2438
|
-
*
|
|
2439
|
-
* @param {string}
|
|
2440
|
-
* @param {string} clientId - ID of the disconnected client
|
|
2440
|
+
* Migrate the host role to another participant
|
|
2441
|
+
* @param {string} roomId - ID of the room
|
|
2442
|
+
* @param {string} clientId - ID of the current host
|
|
2441
2443
|
*/
|
|
2442
|
-
#
|
|
2444
|
+
#migrateHost(roomId, clientId) {
|
|
2443
2445
|
const room = this.#rooms[roomId];
|
|
2444
|
-
if (room
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
});
|
|
2458
|
-
}
|
|
2459
|
-
}
|
|
2446
|
+
if (!room) throw new Error("Room not found.");
|
|
2447
|
+
if (room.host !== clientId) throw new Error("Client is not the host.");
|
|
2448
|
+
|
|
2449
|
+
const remainingParticipants = room.participants.filter(p => p !== clientId);
|
|
2450
|
+
|
|
2451
|
+
if (remainingParticipants.length >= 1) {
|
|
2452
|
+
// Inform all participants about the new host
|
|
2453
|
+
room.host = remainingParticipants[0];
|
|
2454
|
+
remainingParticipants.forEach(p => {
|
|
2455
|
+
const client = this.#clients.get(p);
|
|
2456
|
+
if (client) client.send(encode({
|
|
2457
|
+
type: 'host_migrated',
|
|
2458
|
+
newHost: room.host,
|
|
2459
|
+
}), { binary: true });
|
|
2460
|
+
});
|
|
2461
|
+
} else room.host = null; // The next person to join will become the host
|
|
2460
2462
|
}
|
|
2461
2463
|
|
|
2462
2464
|
/**
|
|
@@ -2512,16 +2514,15 @@ class PlaySocketServer {
|
|
|
2512
2514
|
|
|
2513
2515
|
// If client was in a room, check if they were the host & migrate
|
|
2514
2516
|
const roomId = this.#clientRooms.get(ws.clientId);
|
|
2515
|
-
if (roomId != null) this.#
|
|
2517
|
+
if (roomId != null && this.#rooms[roomId]?.host === ws.clientId) this.#migrateHost(roomId, ws.clientId);
|
|
2516
2518
|
|
|
2517
|
-
if (ws.willfulDisconnect)
|
|
2518
|
-
|
|
2519
|
-
} else {
|
|
2519
|
+
if (ws.willfulDisconnect) this.#disconnectClient(ws); // Immediate disconnection
|
|
2520
|
+
else {
|
|
2520
2521
|
// Pending complete disconnection with 5s grace period to allow for reconnections
|
|
2521
2522
|
this.#pendingDisconnects.set(ws.clientId, {
|
|
2522
2523
|
timeout: setTimeout(() => {
|
|
2523
2524
|
this.#disconnectClient(ws);
|
|
2524
|
-
},
|
|
2525
|
+
}, RECONNECT_GRACE_PERIOD)
|
|
2525
2526
|
});
|
|
2526
2527
|
}
|
|
2527
2528
|
}
|
|
@@ -2535,29 +2536,8 @@ class PlaySocketServer {
|
|
|
2535
2536
|
#disconnectClient(ws) {
|
|
2536
2537
|
this.#pendingDisconnects.delete(ws.clientId);
|
|
2537
2538
|
this.#clientTokens.delete(ws.clientId);
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
if (room) {
|
|
2542
|
-
room.participants = room.participants.filter(p => p !== ws.clientId); // Remove client from room
|
|
2543
|
-
this.#clientRooms.delete(ws.clientId);
|
|
2544
|
-
|
|
2545
|
-
if (room.participants?.length === 0 && room.host !== "server") {
|
|
2546
|
-
this.destroyRoom(roomId); // Destroy room if now empty & not created by server
|
|
2547
|
-
roomId = null; // If room was destroyed, set roomId to null
|
|
2548
|
-
} else {
|
|
2549
|
-
// Notify remaining participants
|
|
2550
|
-
room.participants.forEach(p => {
|
|
2551
|
-
const client = this.#clients.get(p);
|
|
2552
|
-
if (client) client.send(encode({
|
|
2553
|
-
type: 'client_disconnected',
|
|
2554
|
-
client: ws.clientId,
|
|
2555
|
-
participantCount: room.participants.length
|
|
2556
|
-
}), { binary: true });
|
|
2557
|
-
});
|
|
2558
|
-
}
|
|
2559
|
-
}
|
|
2560
|
-
|
|
2539
|
+
const roomId = this.#clientRooms.get(ws.clientId);
|
|
2540
|
+
if (roomId && this.#rooms[roomId]) this.#leaveRoom(ws.clientId, roomId);
|
|
2561
2541
|
this.#triggerEvent("clientDisconnected", ws.clientId, roomId);
|
|
2562
2542
|
}
|
|
2563
2543
|
|
|
@@ -2576,17 +2556,37 @@ class PlaySocketServer {
|
|
|
2576
2556
|
}
|
|
2577
2557
|
|
|
2578
2558
|
/**
|
|
2579
|
-
* Kick a
|
|
2559
|
+
* Kick a client from the server
|
|
2580
2560
|
* @param {string} clientId - Client ID
|
|
2581
2561
|
* @param {string} [reason] - Optional reason
|
|
2582
2562
|
*/
|
|
2583
2563
|
kick(clientId, reason) {
|
|
2584
2564
|
const client = this.#clients.get(clientId);
|
|
2585
|
-
if (client)
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2565
|
+
if (!client) return; // Client not connected, skip
|
|
2566
|
+
client.willfulDisconnect = true;
|
|
2567
|
+
client.send(encode({ type: 'kicked', reason }), { binary: true });
|
|
2568
|
+
client.close();
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
/**
|
|
2572
|
+
* Move client into a different room
|
|
2573
|
+
* @param {string} clientId - Client ID
|
|
2574
|
+
* @param {string} roomId - Room ID (Target)
|
|
2575
|
+
*/
|
|
2576
|
+
move(clientId, roomId) {
|
|
2577
|
+
const client = this.#clients.get(clientId);
|
|
2578
|
+
const oldRoomId = this.#clientRooms.get(clientId);
|
|
2579
|
+
const oldRoom = this.#rooms[oldRoomId];
|
|
2580
|
+
const targetRoom = this.#rooms[roomId];
|
|
2581
|
+
if (!client) throw new Error("Client not found.");
|
|
2582
|
+
if (!oldRoomId) throw new Error("Client is not in a room.");
|
|
2583
|
+
if (!oldRoom) throw new Error("Client room not found.");
|
|
2584
|
+
if (oldRoomId === roomId) throw new Error("Client is already in target room.");
|
|
2585
|
+
if (!targetRoom) throw new Error("Target room not found.");
|
|
2586
|
+
if (targetRoom.participants.length >= targetRoom.size) throw new Error("Target room is full.");
|
|
2587
|
+
if (oldRoom.host === clientId) this.#migrateHost(oldRoomId, clientId);
|
|
2588
|
+
this.#leaveRoom(clientId, oldRoomId);
|
|
2589
|
+
this.#joinRoom(clientId, roomId);
|
|
2590
2590
|
}
|
|
2591
2591
|
|
|
2592
2592
|
/**
|
|
@@ -2618,7 +2618,7 @@ class PlaySocketServer {
|
|
|
2618
2618
|
*/
|
|
2619
2619
|
getRoomStorage(roomId) {
|
|
2620
2620
|
const room = this.#rooms[roomId];
|
|
2621
|
-
if (room) return room.crdtManager.
|
|
2621
|
+
if (room) return room.crdtManager.propertyStore;
|
|
2622
2622
|
}
|
|
2623
2623
|
|
|
2624
2624
|
/**
|
|
@@ -2652,7 +2652,7 @@ class PlaySocketServer {
|
|
|
2652
2652
|
}
|
|
2653
2653
|
|
|
2654
2654
|
/**
|
|
2655
|
-
* Create a room
|
|
2655
|
+
* Create a room
|
|
2656
2656
|
* @param {object} [initialStorage] - Optional initial storage object
|
|
2657
2657
|
* @param {number} [size] - Max. room size, up to 500
|
|
2658
2658
|
* @param {string} [host] - Host ID, defaults to "server" (when set to "server", room will not be deleted if all clients leave)
|
|
@@ -2669,7 +2669,7 @@ class PlaySocketServer {
|
|
|
2669
2669
|
}
|
|
2670
2670
|
}
|
|
2671
2671
|
|
|
2672
|
-
if (!newRoomId) throw new Error("No available ID found.")
|
|
2672
|
+
if (!newRoomId) throw new Error("No available ID found.");
|
|
2673
2673
|
const roomCrdtManager = new CRDTManager(this.#debug);
|
|
2674
2674
|
|
|
2675
2675
|
if (initialStorage) Object.entries(initialStorage)?.forEach(([key, value]) => {
|
|
@@ -2686,7 +2686,82 @@ class PlaySocketServer {
|
|
|
2686
2686
|
|
|
2687
2687
|
this.#triggerEvent("roomCreated", newRoomId);
|
|
2688
2688
|
if (this.#debug) console.log(`Room ${newRoomId} created with initial storage:`, initialStorage);
|
|
2689
|
-
return { state: roomCrdtManager.
|
|
2689
|
+
return { state: roomCrdtManager.state, id: newRoomId };
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
/**
|
|
2693
|
+
* Make a client join a room
|
|
2694
|
+
* @param {string} clientId - Client ID
|
|
2695
|
+
* @param {string} roomId - Room ID
|
|
2696
|
+
*/
|
|
2697
|
+
#joinRoom(clientId, roomId) {
|
|
2698
|
+
const room = this.#rooms[roomId];
|
|
2699
|
+
const client = this.#clients.get(clientId);
|
|
2700
|
+
if (!room) throw new Error("Room not found.");
|
|
2701
|
+
if (!client) throw new Error("Client not found.");
|
|
2702
|
+
if (this.#clientRooms.get(clientId)) throw new Error("Already in a room.");
|
|
2703
|
+
if (room.participants.length >= room.size) throw new Error("Room full.");
|
|
2704
|
+
|
|
2705
|
+
room.participants.push(clientId);
|
|
2706
|
+
this.#clientRooms.set(clientId, roomId);
|
|
2707
|
+
|
|
2708
|
+
// Notify joiner with initial storage
|
|
2709
|
+
if (this.#debug) console.log("Room state sent for join:", room.crdtManager.state);
|
|
2710
|
+
client.send(encode({
|
|
2711
|
+
type: 'join_accepted',
|
|
2712
|
+
roomId,
|
|
2713
|
+
state: room.crdtManager.state,
|
|
2714
|
+
participantCount: room.participants.length,
|
|
2715
|
+
host: room.host,
|
|
2716
|
+
version: this.#roomVersions.get(roomId)
|
|
2717
|
+
}), { binary: true });
|
|
2718
|
+
|
|
2719
|
+
// If the room has no host (previous host disconnected and was the only participant), make joiner the host
|
|
2720
|
+
// Since the room had to be empty, only the joiner needs to be notified
|
|
2721
|
+
const becameHost = room.host === null;
|
|
2722
|
+
if (becameHost) {
|
|
2723
|
+
room.host = clientId;
|
|
2724
|
+
client.send(encode({
|
|
2725
|
+
type: 'host_migrated',
|
|
2726
|
+
newHost: room.host,
|
|
2727
|
+
}), { binary: true });
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
// Notify existing participants
|
|
2731
|
+
room.participants.forEach(p => {
|
|
2732
|
+
if (p === clientId) return;
|
|
2733
|
+
const client = this.#clients.get(p);
|
|
2734
|
+
if (client) {
|
|
2735
|
+
client.send(encode({
|
|
2736
|
+
type: 'client_joined',
|
|
2737
|
+
client: clientId,
|
|
2738
|
+
participantCount: room.participants.length
|
|
2739
|
+
}), { binary: true });
|
|
2740
|
+
}
|
|
2741
|
+
});
|
|
2742
|
+
this.#triggerEvent("clientJoinedRoom", clientId, roomId);
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
#leaveRoom(clientId, roomId) {
|
|
2746
|
+
const room = this.#rooms[roomId];
|
|
2747
|
+
if (!room) throw new Error("Room not found.");
|
|
2748
|
+
|
|
2749
|
+
room.participants = room.participants.filter(p => p !== clientId); // Remove client from room
|
|
2750
|
+
this.#clientRooms.delete(clientId);
|
|
2751
|
+
|
|
2752
|
+
if (room.participants.length === 0 && room.host !== "server") {
|
|
2753
|
+
this.destroyRoom(roomId); // Destroy room if now empty & not by server
|
|
2754
|
+
} else {
|
|
2755
|
+
// Notify remaining participants
|
|
2756
|
+
room.participants.forEach(p => {
|
|
2757
|
+
const client = this.#clients.get(p);
|
|
2758
|
+
if (client) client.send(encode({
|
|
2759
|
+
type: 'client_left',
|
|
2760
|
+
client: clientId,
|
|
2761
|
+
participantCount: room.participants.length
|
|
2762
|
+
}), { binary: true });
|
|
2763
|
+
});
|
|
2764
|
+
}
|
|
2690
2765
|
}
|
|
2691
2766
|
|
|
2692
2767
|
/**
|
|
@@ -2695,9 +2770,9 @@ class PlaySocketServer {
|
|
|
2695
2770
|
*/
|
|
2696
2771
|
destroyRoom(roomId) {
|
|
2697
2772
|
const room = this.#rooms[roomId];
|
|
2698
|
-
if (!room)
|
|
2773
|
+
if (!room) throw new Error("Room not found.");
|
|
2699
2774
|
|
|
2700
|
-
// Disconnect
|
|
2775
|
+
// Disconnect clients if still in room
|
|
2701
2776
|
const participants = room.participants;
|
|
2702
2777
|
participants.forEach((clientId) => {
|
|
2703
2778
|
this.kick(clientId, "Room destroyed by server.");
|
|
@@ -2725,7 +2800,7 @@ class PlaySocketServer {
|
|
|
2725
2800
|
});
|
|
2726
2801
|
}
|
|
2727
2802
|
|
|
2728
|
-
get
|
|
2803
|
+
get rooms() { return structuredClone(this.#rooms) }
|
|
2729
2804
|
}
|
|
2730
2805
|
|
|
2731
|
-
export { PlaySocketServer as default };
|
|
2806
|
+
export { RECONNECT_GRACE_PERIOD, PlaySocketServer as default };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "playsocketjs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "WebSocket wrapper for creating simple multiplayer systems with ease.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "dist/playsocket-client.js",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"start": "node example/example-server.js",
|
|
20
|
-
"test": "
|
|
20
|
+
"test": "npm run build && npx playwright test --config e2e/playwright.config.js",
|
|
21
|
+
"test:headed": "npm run build && npx playwright test --config e2e/playwright.config.js --headed",
|
|
21
22
|
"build": "rollup -c rollup.config.js",
|
|
22
23
|
"lint": "eslint ./src"
|
|
23
24
|
},
|
|
@@ -39,11 +40,12 @@
|
|
|
39
40
|
},
|
|
40
41
|
"homepage": "https://github.com/therealPaulPlay/PlaySocketJS#readme",
|
|
41
42
|
"devDependencies": {
|
|
43
|
+
"@playwright/test": "^1.58.1",
|
|
42
44
|
"@rollup/plugin-node-resolve": "^16.0.0",
|
|
43
45
|
"eslint": "^9.35.0",
|
|
44
46
|
"eslint-plugin-jsdoc": "^54.4.0",
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
+
"globals": "^16.3.0",
|
|
48
|
+
"rollup": "^4.31.0"
|
|
47
49
|
},
|
|
48
50
|
"peerDependencies": {
|
|
49
51
|
"ws": "^8.18.1"
|
|
@@ -51,4 +53,4 @@
|
|
|
51
53
|
"dependencies": {
|
|
52
54
|
"@msgpack/msgpack": "^3.1.1"
|
|
53
55
|
}
|
|
54
|
-
}
|
|
56
|
+
}
|