playsocketjs 3.1.2 → 4.1.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 +244 -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 +143 -0
- package/types/universal/constants.d.ts +7 -0
- package/types/universal/crdtManager.d.ts +147 -0
- package/dist/playsocket-client.js +0 -2614
- package/dist/playsocket-server.js +0 -2816
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Custom properties that PlaySocketServer attaches to ws WebSocket instances,
|
|
2
|
+
// added to the ws module's own WebSocket type via declaration merging
|
|
3
|
+
import "ws";
|
|
4
|
+
|
|
5
|
+
declare module "ws" {
|
|
6
|
+
interface WebSocket {
|
|
7
|
+
uuid: string;
|
|
8
|
+
isAlive: boolean;
|
|
9
|
+
clientId?: string;
|
|
10
|
+
isTerminating?: boolean;
|
|
11
|
+
willfulDisconnect?: boolean;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heartbeat ping interval in milliseconds
|
|
3
|
+
* Also used as the minimum age for CRDT garbage collection - operations are
|
|
4
|
+
* retained for at least one heartbeat cycle to ensure even slow clients can never
|
|
5
|
+
* be desynced as a result of gc
|
|
6
|
+
*/
|
|
7
|
+
export const HEARTBEAT_INTERVAL = 5000;
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { HEARTBEAT_INTERVAL } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
const CONSOLE_PREFIX = "PlaySocket CRDT manager: ";
|
|
4
|
+
|
|
5
|
+
/** @typedef {"set" | "number-increment" | "array-add" | "array-add-unique" | "array-remove-matching" | "array-update-matching" | "object-set-key" | "object-remove-key"} PropertyUpdateType */
|
|
6
|
+
|
|
7
|
+
/** @typedef {[string, number][]} VectorClockEntries */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} OperationData
|
|
11
|
+
* @property {PropertyUpdateType} type - Operation type
|
|
12
|
+
* @property {*} value - Value
|
|
13
|
+
* @property {*} [secondValue] - Second value (needed for some operations)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {object} Operation
|
|
18
|
+
* @property {OperationData} data - Operation data
|
|
19
|
+
* @property {VectorClockEntries} vectorClock - Vector clock entries at creation time
|
|
20
|
+
* @property {string} source - ID of the replica that created the operation
|
|
21
|
+
* @property {string} uuid - Unique operation ID
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} PropertyUpdate
|
|
26
|
+
* @property {string} key - Storage key
|
|
27
|
+
* @property {Operation} operation - New operation
|
|
28
|
+
* @property {VectorClockEntries} vectorClock - Vector clock entries of the replica
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {object} CRDTState
|
|
33
|
+
* @property {[string, Operation[]][]} keyOperations - Operations per storage key
|
|
34
|
+
* @property {VectorClockEntries} vectorClock - Vector clock entries
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* CRDT Manager
|
|
39
|
+
* Custom conflict-free replicated data type system with vector clocks
|
|
40
|
+
*/
|
|
41
|
+
export default class CRDTManager {
|
|
42
|
+
// Storage
|
|
43
|
+
/** @type {string} */
|
|
44
|
+
#replicaId;
|
|
45
|
+
/** @type {Map<string, Operation[]>} */
|
|
46
|
+
#keyOperations = new Map();
|
|
47
|
+
/** @type {Map<string, number>} */
|
|
48
|
+
#vectorClock = new Map();
|
|
49
|
+
|
|
50
|
+
/** @type {Record<string, any>} */
|
|
51
|
+
#propertyStore = {}; // Current local values as key/value store object
|
|
52
|
+
/** @type {Record<string, any>} */
|
|
53
|
+
#lastPropertyStore = {}; // Last property store to compare against
|
|
54
|
+
|
|
55
|
+
// Local Garbage Collection
|
|
56
|
+
#lastGCCheck = 0;
|
|
57
|
+
/** @type {Map<string, number>} */
|
|
58
|
+
#opUuidTimestamp = new Map(); // Map every operation to a timestamp for garbage collection
|
|
59
|
+
|
|
60
|
+
// Debug
|
|
61
|
+
#debug = false;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Create a new instance
|
|
65
|
+
* @param {boolean} [debug=false] - Enable debug logging
|
|
66
|
+
*/
|
|
67
|
+
constructor(debug) {
|
|
68
|
+
if (debug) this.#debug = true;
|
|
69
|
+
this.#replicaId = crypto.randomUUID();
|
|
70
|
+
this.#vectorClock.set(this.#replicaId, 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Import the entire state of the CRDT manager (this overwrites the old state)
|
|
75
|
+
* @param {CRDTState} state - Full state of the CRDT manager
|
|
76
|
+
*/
|
|
77
|
+
importState(state) {
|
|
78
|
+
try {
|
|
79
|
+
const { keyOperations, vectorClock } = state;
|
|
80
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + "Importing state:", state);
|
|
81
|
+
|
|
82
|
+
// Resets
|
|
83
|
+
this.#opUuidTimestamp.clear();
|
|
84
|
+
this.#propertyStore = {};
|
|
85
|
+
this.#lastPropertyStore = {};
|
|
86
|
+
|
|
87
|
+
this.#keyOperations = new Map(keyOperations); // Rebuild the operations map
|
|
88
|
+
this.#vectorClock = new Map(vectorClock); // Rebuild the vector clock map
|
|
89
|
+
if (!this.#vectorClock.has(this.#replicaId)) this.#vectorClock.set(this.#replicaId, 0); // Add own vector clock if it wasn't present in the imported state
|
|
90
|
+
|
|
91
|
+
// Map all operation uuids to current timestamp
|
|
92
|
+
this.#keyOperations.forEach((value, _key) => {
|
|
93
|
+
value.forEach((operation) => {
|
|
94
|
+
this.#opUuidTimestamp.set(operation.uuid, Date.now());
|
|
95
|
+
})
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Process each key to update local values
|
|
99
|
+
for (const key of this.#keyOperations.keys()) this.#processLocalProperty(key);
|
|
100
|
+
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error(CONSOLE_PREFIX + "Failed to import state:", error);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Import property update
|
|
108
|
+
* @param {PropertyUpdate} update - Property update
|
|
109
|
+
*/
|
|
110
|
+
importPropertyUpdate(update) {
|
|
111
|
+
try {
|
|
112
|
+
const { key, operation: rawOperation, vectorClock } = update;
|
|
113
|
+
const operation = /** @type {Operation} */ (this.#sanitizeValue(rawOperation));
|
|
114
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + "Importing update:", update); // Debug
|
|
115
|
+
|
|
116
|
+
// Check key limit to safeguard against too many keys
|
|
117
|
+
if (this.#keyOperations.size >= 100 && !this.#keyOperations.has(key)) throw new Error("Key limit exceeded!");
|
|
118
|
+
|
|
119
|
+
// Get COPY of current ops (or empty array if none yet)
|
|
120
|
+
const currentOps = [...(this.#keyOperations.get(key) || [])];
|
|
121
|
+
// Merge vector clocks (always take max value)
|
|
122
|
+
for (const [id, counter] of vectorClock) {
|
|
123
|
+
if (!this.#vectorClock.has(id) || this.#vectorClock.get(id) < counter) {
|
|
124
|
+
this.#vectorClock.set(id, counter);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Safeguard against massive vector clocks
|
|
129
|
+
if (this.#vectorClock.size > 1000) this.#vectorClock = new Map([...this.#vectorClock].slice(-100));
|
|
130
|
+
|
|
131
|
+
// Add new operation if it's not already added
|
|
132
|
+
const existingUuids = new Set(currentOps.map(op => op.uuid));
|
|
133
|
+
if (operation.uuid && !existingUuids.has(operation.uuid)) {
|
|
134
|
+
currentOps.push({ ...operation }); // Add operation
|
|
135
|
+
this.#opUuidTimestamp.set(operation.uuid, Date.now()); // Add timestamp for gc
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Sort, update operations & local value
|
|
139
|
+
this.#keyOperations.set(key, this.#sortByVectorClock(currentOps));
|
|
140
|
+
this.#processLocalProperty(key);
|
|
141
|
+
this.#checkGarbageCollection();
|
|
142
|
+
|
|
143
|
+
} catch (error) {
|
|
144
|
+
console.error(CONSOLE_PREFIX + "Failed to import property update:", error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Revert a local property update by removing the operation and recomputing the affected key
|
|
150
|
+
* Should NOT be used on the server or for synced ops, only for reverting local optimistic updates
|
|
151
|
+
* @param {PropertyUpdate} update - Property update
|
|
152
|
+
*/
|
|
153
|
+
revertPropertyUpdate(update) {
|
|
154
|
+
try {
|
|
155
|
+
const key = update.key;
|
|
156
|
+
const opUuid = update.operation.uuid;
|
|
157
|
+
|
|
158
|
+
const currentOps = this.#keyOperations.get(key);
|
|
159
|
+
if (!currentOps?.some(op => op.uuid === opUuid)) return; // Operation not found
|
|
160
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + `Removing operation ${opUuid} for key ${key} as part of property update reversion.`);
|
|
161
|
+
|
|
162
|
+
// The own vector clock entry isn't decremented because later ops may already embed the incremented counter
|
|
163
|
+
// and reusing a counter value would corrupt operation ordering (while a gap is harmless)
|
|
164
|
+
const remainingOps = currentOps.filter(op => op.uuid !== opUuid);
|
|
165
|
+
this.#opUuidTimestamp.delete(opUuid);
|
|
166
|
+
|
|
167
|
+
if (remainingOps.length) this.#keyOperations.set(key, remainingOps);
|
|
168
|
+
else this.#keyOperations.delete(key); // This was the only operation for this key, remove key
|
|
169
|
+
this.#processLocalProperty(key);
|
|
170
|
+
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error(CONSOLE_PREFIX + "Failed to revert property update:", error);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Update a property
|
|
178
|
+
* @param {string} key - Storage key
|
|
179
|
+
* @param {PropertyUpdateType} type - Operation type
|
|
180
|
+
* @param {*} value - Value
|
|
181
|
+
* @param {*} [secondValue] - Second value (needed for some operations)
|
|
182
|
+
* @returns {PropertyUpdate | undefined} - Returns the property update
|
|
183
|
+
*/
|
|
184
|
+
updateProperty(key, type, value, secondValue) {
|
|
185
|
+
try {
|
|
186
|
+
// Sanitize inputs
|
|
187
|
+
value = this.#sanitizeValue(value);
|
|
188
|
+
secondValue = this.#sanitizeValue(secondValue);
|
|
189
|
+
|
|
190
|
+
// Debug log
|
|
191
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + `Updating property with key ${key}, type ${type}, value ${value} and secondValue ${secondValue}.`);
|
|
192
|
+
|
|
193
|
+
// Increment vector clock
|
|
194
|
+
const counter = this.#vectorClock.get(this.#replicaId) || 0;
|
|
195
|
+
this.#vectorClock.set(this.#replicaId, counter + 1);
|
|
196
|
+
|
|
197
|
+
// Assign shallow copy of current ops or fall back to empty array
|
|
198
|
+
const currentOps = [...(this.#keyOperations.get(key) || [])];
|
|
199
|
+
|
|
200
|
+
// Add operation
|
|
201
|
+
const newOp = this.#createOperation({ type, value, secondValue }, Array.from(this.#vectorClock.entries()));
|
|
202
|
+
currentOps.push(newOp);
|
|
203
|
+
this.#keyOperations.set(key, currentOps); // Update the operations (no need to sort via vector clock since local updates are always the latest)
|
|
204
|
+
this.#processLocalProperty(key); // Process local value
|
|
205
|
+
this.#checkGarbageCollection();
|
|
206
|
+
|
|
207
|
+
// Return the property update with the new operation (this can be imported using importPropertyUpdate)
|
|
208
|
+
return {
|
|
209
|
+
key,
|
|
210
|
+
operation: { ...newOp },
|
|
211
|
+
vectorClock: Array.from(this.#vectorClock.entries())
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
} catch (error) {
|
|
215
|
+
console.error(CONSOLE_PREFIX + `Failed to add operation for key ${key}:`, error);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Check if garbage collected can be performed & run it
|
|
221
|
+
*/
|
|
222
|
+
#checkGarbageCollection() {
|
|
223
|
+
const MIN_GC_DELAY = 1000; // Minimum 1s delay between garbage collection runs
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
if (Date.now() - this.#lastGCCheck < MIN_GC_DELAY) return;
|
|
227
|
+
this.#lastGCCheck = Date.now()
|
|
228
|
+
|
|
229
|
+
for (const [key, operations] of this.#keyOperations.entries()) {
|
|
230
|
+
if (operations?.length < 5) continue; // Min op amount per key for garbage collection to run (otherwise not worth it)
|
|
231
|
+
|
|
232
|
+
let retainCount = operations.length;
|
|
233
|
+
operations.forEach((op, index) => {
|
|
234
|
+
// Count how many ops, from last to latest, are older than the min-age in a row
|
|
235
|
+
if (op.uuid && this.#opUuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuidTimestamp.get(op.uuid)) > HEARTBEAT_INTERVAL) {
|
|
236
|
+
const removeCount = index + 1;
|
|
237
|
+
retainCount = operations.length - removeCount;
|
|
238
|
+
this.#opUuidTimestamp.delete(op.uuid); // Remove from timestamps if set to be deleted
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (retainCount < operations.length) {
|
|
243
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + `Running garbage collection for key ${key} with current operations:`, operations);
|
|
244
|
+
const retainOps = operations.slice(-retainCount); // Newest ops
|
|
245
|
+
const removeOps = operations.slice(0, -retainCount); // Oldest ops (start = idx 0, end = retainCount counted from right side)
|
|
246
|
+
const baselineVectorClock = removeOps[removeOps.length - 1]?.vectorClock || []; // Use the vector clock from the last operation that we remove/overwrite
|
|
247
|
+
|
|
248
|
+
// Calculate the value at the point where retained operations start
|
|
249
|
+
let baselineValue = null;
|
|
250
|
+
for (const op of removeOps) {
|
|
251
|
+
if (!op.data) continue;
|
|
252
|
+
baselineValue = this.#handleOperation(
|
|
253
|
+
baselineValue,
|
|
254
|
+
op.data.type,
|
|
255
|
+
op.data.value,
|
|
256
|
+
op.data.secondValue
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Create a compact operation with baseline value and appropriate vector clock
|
|
261
|
+
const compactOp = this.#createOperation({ type: "set", value: baselineValue }, baselineVectorClock);
|
|
262
|
+
|
|
263
|
+
// Debug log
|
|
264
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + "Operations after garbage collection for this key:", [compactOp, ...retainOps]);
|
|
265
|
+
|
|
266
|
+
// Combine compact op with retained operations
|
|
267
|
+
this.#keyOperations.set(key, [compactOp, ...retainOps]);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} catch (error) {
|
|
271
|
+
console.error(CONSOLE_PREFIX + "Error running garbage collection:", error);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Create an operation object
|
|
277
|
+
* @param {OperationData} data - Operation data, consists of type, value and secondValue
|
|
278
|
+
* @param {VectorClockEntries} vectorClock - Vector clock of the replica/client
|
|
279
|
+
* @returns {Operation} - Operation object
|
|
280
|
+
*/
|
|
281
|
+
#createOperation(data, vectorClock) {
|
|
282
|
+
const newOperation = {
|
|
283
|
+
data,
|
|
284
|
+
vectorClock,
|
|
285
|
+
source: this.#replicaId,
|
|
286
|
+
uuid: crypto.randomUUID()
|
|
287
|
+
}
|
|
288
|
+
this.#opUuidTimestamp.set(newOperation.uuid, Date.now());
|
|
289
|
+
return newOperation;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Process a property's value by applying all operations and set it in the property store
|
|
294
|
+
* @param {string} key - Storage key
|
|
295
|
+
*/
|
|
296
|
+
#processLocalProperty(key) {
|
|
297
|
+
try {
|
|
298
|
+
const ops = this.#keyOperations.get(key);
|
|
299
|
+
if (!ops?.length) {
|
|
300
|
+
delete this.#propertyStore[key]; // Delete empty key
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Apply all operations in order
|
|
305
|
+
let value = null;
|
|
306
|
+
|
|
307
|
+
for (const op of ops) {
|
|
308
|
+
if (!op.data) continue;
|
|
309
|
+
value = this.#handleOperation(
|
|
310
|
+
value,
|
|
311
|
+
op.data.type,
|
|
312
|
+
op.data.value,
|
|
313
|
+
op.data.secondValue
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
this.#propertyStore[key] = value; // Save locally
|
|
318
|
+
} catch (error) {
|
|
319
|
+
console.error(CONSOLE_PREFIX + `Failed to process property for key ${key}:`, error);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Sort operations by vector clock (causal order)
|
|
325
|
+
* @param {Operation[]} operations - Full operations that include vector clocks, data and a source
|
|
326
|
+
* @returns {Operation[]} - Sorted operations
|
|
327
|
+
*/
|
|
328
|
+
#sortByVectorClock(operations) {
|
|
329
|
+
return [...operations].sort((a, b) => {
|
|
330
|
+
const clockA = new Map(a.vectorClock || []);
|
|
331
|
+
const clockB = new Map(b.vectorClock || []);
|
|
332
|
+
|
|
333
|
+
// First sort by causal relationship
|
|
334
|
+
let aGreater = false, bGreater = false;
|
|
335
|
+
|
|
336
|
+
const allIds = new Set([...clockA.keys(), ...clockB.keys()]);
|
|
337
|
+
for (const id of allIds) {
|
|
338
|
+
const countA = clockA.get(id) || 0;
|
|
339
|
+
const countB = clockB.get(id) || 0;
|
|
340
|
+
|
|
341
|
+
if (countA > countB) aGreater = true;
|
|
342
|
+
if (countA < countB) bGreater = true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Determine causal relationship (if one is greater, and the other is smaller, it's clear)
|
|
346
|
+
if (aGreater && !bGreater) return 1; // a happens after b
|
|
347
|
+
if (!aGreater && bGreater) return -1; // a happens before b
|
|
348
|
+
|
|
349
|
+
// Concurrent (unclear): Compare highest counter in each (sort by which one is ahead)
|
|
350
|
+
const maxA = Math.max(...Array.from(clockA.values()), 0);
|
|
351
|
+
const maxB = Math.max(...Array.from(clockB.values()), 0);
|
|
352
|
+
if (maxA !== maxB) return maxA - maxB;
|
|
353
|
+
|
|
354
|
+
// Final tiebreak: Sort by ID of Replica that actually generated the operation
|
|
355
|
+
return a.source.localeCompare(b.source);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Handle an operation
|
|
361
|
+
* @param {*} curValue - Current value of the storage key
|
|
362
|
+
* @param {PropertyUpdateType} type - Operation type
|
|
363
|
+
* @param {*} value - Value
|
|
364
|
+
* @param {*} [secondValue] - Second value (needed for some operations)
|
|
365
|
+
* @returns {*} - Value after the operation
|
|
366
|
+
*/
|
|
367
|
+
#handleOperation(curValue, type, value, secondValue) {
|
|
368
|
+
try {
|
|
369
|
+
// Deep copy to avoid reference issues in case value is or contains object(s)
|
|
370
|
+
curValue = structuredClone(curValue);
|
|
371
|
+
|
|
372
|
+
// Set operation
|
|
373
|
+
if (type === "set") return value;
|
|
374
|
+
|
|
375
|
+
// Number increment operation
|
|
376
|
+
if (type === "number-increment") {
|
|
377
|
+
if (!Number.isFinite(curValue)) curValue = 0; // Auto-convert to number if current value isn't a finite number
|
|
378
|
+
if (Number.isFinite(value)) curValue += value; // Negative values are allowed, increment can be used for decrementing (non-finite values are ignored)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Array operations
|
|
382
|
+
if (type.startsWith("array")) {
|
|
383
|
+
if (!Array.isArray(curValue)) curValue = []; // Auto-convert to array if current value isn't one
|
|
384
|
+
|
|
385
|
+
// Comparison function
|
|
386
|
+
const isObject = typeof value === "object" && value !== null;
|
|
387
|
+
/** @param {*} item */
|
|
388
|
+
const compare = (item) => {
|
|
389
|
+
if (isObject && typeof item === "object" && item !== null) return JSON.stringify(item) === JSON.stringify(value); // Deep comparison
|
|
390
|
+
return item === value; // Value comparison
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
switch (type) {
|
|
394
|
+
case "array-add":
|
|
395
|
+
curValue.push(value);
|
|
396
|
+
break;
|
|
397
|
+
case "array-add-unique":
|
|
398
|
+
if (!curValue.some(compare)) curValue.push(value);
|
|
399
|
+
break;
|
|
400
|
+
case "array-remove-matching":
|
|
401
|
+
curValue = curValue.filter((/** @type {*} */ item) => !compare(item));
|
|
402
|
+
break;
|
|
403
|
+
case "array-update-matching": {
|
|
404
|
+
const index = curValue.findIndex(compare);
|
|
405
|
+
if (index !== -1) curValue[index] = secondValue;
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Object operations
|
|
412
|
+
if (type.startsWith("object")) {
|
|
413
|
+
if (typeof curValue !== "object" || curValue === null || Array.isArray(curValue)) curValue = {}; // Auto-convert to object if current value isn't one
|
|
414
|
+
|
|
415
|
+
switch (type) {
|
|
416
|
+
case "object-set-key":
|
|
417
|
+
if (value !== "__proto__") curValue[value] = secondValue; // Assigning __proto__ is not allowed as it would break things
|
|
418
|
+
break;
|
|
419
|
+
case "object-remove-key":
|
|
420
|
+
delete curValue[value];
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return curValue;
|
|
426
|
+
|
|
427
|
+
} catch (error) {
|
|
428
|
+
console.error(CONSOLE_PREFIX + `Failed to handle operation with curValue ${curValue}:`, error);
|
|
429
|
+
return curValue;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Remove HTML to prevent XSS and enforce size limits
|
|
435
|
+
* @param {*} value - Value to sanitize
|
|
436
|
+
* @returns {*} - Sanitized value
|
|
437
|
+
*/
|
|
438
|
+
#sanitizeValue(value) {
|
|
439
|
+
// Check total serialized size
|
|
440
|
+
const jsonString = JSON.stringify(value);
|
|
441
|
+
if (jsonString?.length > 50000) throw new Error("Value too large!"); // 50KB limit
|
|
442
|
+
|
|
443
|
+
if (typeof value === "string") return (value.includes("<") || value.includes(">")) ? value.replace(/[<>]/g, "") : value;
|
|
444
|
+
if (Array.isArray(value)) return value.map(item => this.#sanitizeValue(item));
|
|
445
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, this.#sanitizeValue(v)]));
|
|
446
|
+
return value;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Get property store
|
|
451
|
+
* @returns {Record<string, any>} - Object with key value pairs
|
|
452
|
+
*/
|
|
453
|
+
get propertyStore() {
|
|
454
|
+
try {
|
|
455
|
+
return structuredClone(this.#propertyStore); // Return deep clone
|
|
456
|
+
} catch {
|
|
457
|
+
console.error(CONSOLE_PREFIX + "Failed to parse property store")
|
|
458
|
+
return {}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Check for changes in the local property store
|
|
464
|
+
* @returns {boolean} - Check if any values changed going from the last to the current property update
|
|
465
|
+
*/
|
|
466
|
+
get didPropertiesChange() {
|
|
467
|
+
try {
|
|
468
|
+
if (JSON.stringify(this.#propertyStore) !== JSON.stringify(this.#lastPropertyStore)) {
|
|
469
|
+
this.#lastPropertyStore = structuredClone(this.#propertyStore);
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
} catch {
|
|
473
|
+
console.error(CONSOLE_PREFIX + "Failed to stringify and compare property stores")
|
|
474
|
+
}
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Export state (can be imported using importState, converts the maps to arrays for serialization)
|
|
480
|
+
* @returns {CRDTState} - State
|
|
481
|
+
*/
|
|
482
|
+
get state() {
|
|
483
|
+
return {
|
|
484
|
+
keyOperations: [...this.#keyOperations.entries()],
|
|
485
|
+
vectorClock: [...this.#vectorClock.entries()]
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Get the operation details from a property update
|
|
492
|
+
* @param {PropertyUpdate} update - Property update
|
|
493
|
+
* @returns {{key: string | undefined, type: PropertyUpdateType | undefined, value: *, secondValue: *}} - Operation details
|
|
494
|
+
*/
|
|
495
|
+
export function getUpdateDetails(update) {
|
|
496
|
+
const data = update?.operation?.data;
|
|
497
|
+
return { key: update?.key, type: data?.type, value: data?.value, secondValue: data?.secondValue };
|
|
498
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export type PropertyUpdateType = import("../universal/crdtManager.js").PropertyUpdateType;
|
|
2
|
+
export type PlaySocketOptions = {
|
|
3
|
+
/**
|
|
4
|
+
* - WebSocket server endpoint (e.g. 'wss://example.com/socket')
|
|
5
|
+
*/
|
|
6
|
+
endpoint: string;
|
|
7
|
+
/**
|
|
8
|
+
* - Custom registration data
|
|
9
|
+
*/
|
|
10
|
+
customData?: object;
|
|
11
|
+
/**
|
|
12
|
+
* - Enable debug logging
|
|
13
|
+
*/
|
|
14
|
+
debug?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type ServerMessage = {
|
|
17
|
+
/**
|
|
18
|
+
* - Message type
|
|
19
|
+
*/
|
|
20
|
+
type: string;
|
|
21
|
+
};
|
|
22
|
+
export type PendingPromise = {
|
|
23
|
+
resolve: (value?: any) => void;
|
|
24
|
+
reject: (reason?: any) => void;
|
|
25
|
+
};
|
|
26
|
+
/** @typedef {import("../universal/crdtManager.js").PropertyUpdateType} PropertyUpdateType */
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {object} PlaySocketOptions
|
|
29
|
+
* @property {string} endpoint - WebSocket server endpoint (e.g. 'wss://example.com/socket')
|
|
30
|
+
* @property {object} [customData] - Custom registration data
|
|
31
|
+
* @property {boolean} [debug=false] - Enable debug logging
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {object} ServerMessage
|
|
35
|
+
* @property {string} type - Message type
|
|
36
|
+
*/
|
|
37
|
+
/** @typedef {{ resolve: (value?: any) => void, reject: (reason?: any) => void }} PendingPromise */
|
|
38
|
+
/**
|
|
39
|
+
* PlaySocket Client
|
|
40
|
+
*/
|
|
41
|
+
export default class PlaySocket {
|
|
42
|
+
#private;
|
|
43
|
+
/**
|
|
44
|
+
* Create a new PlaySocket instance
|
|
45
|
+
* @param {string} [id] - Unique identifier for this client (assigned by the server if omitted)
|
|
46
|
+
* @param {PlaySocketOptions} options - Connection options
|
|
47
|
+
*/
|
|
48
|
+
constructor(id?: string, options?: PlaySocketOptions);
|
|
49
|
+
/**
|
|
50
|
+
* Register an event callback
|
|
51
|
+
* @param {string} event - Event name
|
|
52
|
+
* @param {Function} callback - Callback function
|
|
53
|
+
* @returns {Function} - Unsubscribe
|
|
54
|
+
*/
|
|
55
|
+
onEvent(event: string, callback: Function): Function;
|
|
56
|
+
/**
|
|
57
|
+
* Initialize the PlaySocket instance by connecting to the WS server
|
|
58
|
+
* @returns {Promise<string>} Resolves with the client ID when the connection is established
|
|
59
|
+
*/
|
|
60
|
+
init(): Promise<string>;
|
|
61
|
+
/**
|
|
62
|
+
* Create a new room and become host
|
|
63
|
+
* @param {object} [initialStorage] - Initial state
|
|
64
|
+
* @param {number} [size] - Max number of participants
|
|
65
|
+
* @returns {Promise<string>} Resolves with room ID
|
|
66
|
+
*/
|
|
67
|
+
createRoom(initialStorage?: object, size?: number): Promise<string>;
|
|
68
|
+
/**
|
|
69
|
+
* Join an existing room
|
|
70
|
+
* @param {string} roomId - ID of the room
|
|
71
|
+
* @returns {Promise<void>} - Resolves when connected
|
|
72
|
+
*/
|
|
73
|
+
joinRoom(roomId: string): Promise<void>;
|
|
74
|
+
/**
|
|
75
|
+
* Update a value in the shared storage
|
|
76
|
+
* @param {string} key - Storage key
|
|
77
|
+
* @param {PropertyUpdateType} type - Operation type
|
|
78
|
+
* @param {*} value - Value
|
|
79
|
+
* @param {*} [secondValue] - Second value (needed for some operations)
|
|
80
|
+
*/
|
|
81
|
+
updateStorage(key: string, type: PropertyUpdateType, value: any, secondValue?: any): void;
|
|
82
|
+
/**
|
|
83
|
+
* Send a custom request to the server
|
|
84
|
+
* @param {string} name - Name of the request
|
|
85
|
+
* @param {*} [data] - Custom data
|
|
86
|
+
* @returns {Promise<void>} - Request promise
|
|
87
|
+
*/
|
|
88
|
+
sendRequest(name: string, data?: any): Promise<void>;
|
|
89
|
+
/**
|
|
90
|
+
* Destroy the PlaySocket instance and disconnect
|
|
91
|
+
*/
|
|
92
|
+
destroy(): void;
|
|
93
|
+
get participantCount(): number;
|
|
94
|
+
get storage(): Record<string, any>;
|
|
95
|
+
get isHost(): boolean;
|
|
96
|
+
get id(): string;
|
|
97
|
+
}
|