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.
@@ -1,2816 +0,0 @@
1
- import { WebSocketServer } from 'ws';
2
- import { createServer } from 'node:http';
3
-
4
- function utf8Count(str) {
5
- const strLength = str.length;
6
- let byteLength = 0;
7
- let pos = 0;
8
- while (pos < strLength) {
9
- let value = str.charCodeAt(pos++);
10
- if ((value & 0xffffff80) === 0) {
11
- // 1-byte
12
- byteLength++;
13
- continue;
14
- }
15
- else if ((value & 0xfffff800) === 0) {
16
- // 2-bytes
17
- byteLength += 2;
18
- }
19
- else {
20
- // handle surrogate pair
21
- if (value >= 0xd800 && value <= 0xdbff) {
22
- // high surrogate
23
- if (pos < strLength) {
24
- const extra = str.charCodeAt(pos);
25
- if ((extra & 0xfc00) === 0xdc00) {
26
- ++pos;
27
- value = ((value & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000;
28
- }
29
- }
30
- }
31
- if ((value & 0xffff0000) === 0) {
32
- // 3-byte
33
- byteLength += 3;
34
- }
35
- else {
36
- // 4-byte
37
- byteLength += 4;
38
- }
39
- }
40
- }
41
- return byteLength;
42
- }
43
- function utf8EncodeJs(str, output, outputOffset) {
44
- const strLength = str.length;
45
- let offset = outputOffset;
46
- let pos = 0;
47
- while (pos < strLength) {
48
- let value = str.charCodeAt(pos++);
49
- if ((value & 0xffffff80) === 0) {
50
- // 1-byte
51
- output[offset++] = value;
52
- continue;
53
- }
54
- else if ((value & 0xfffff800) === 0) {
55
- // 2-bytes
56
- output[offset++] = ((value >> 6) & 0x1f) | 0xc0;
57
- }
58
- else {
59
- // handle surrogate pair
60
- if (value >= 0xd800 && value <= 0xdbff) {
61
- // high surrogate
62
- if (pos < strLength) {
63
- const extra = str.charCodeAt(pos);
64
- if ((extra & 0xfc00) === 0xdc00) {
65
- ++pos;
66
- value = ((value & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000;
67
- }
68
- }
69
- }
70
- if ((value & 0xffff0000) === 0) {
71
- // 3-byte
72
- output[offset++] = ((value >> 12) & 0x0f) | 0xe0;
73
- output[offset++] = ((value >> 6) & 0x3f) | 0x80;
74
- }
75
- else {
76
- // 4-byte
77
- output[offset++] = ((value >> 18) & 0x07) | 0xf0;
78
- output[offset++] = ((value >> 12) & 0x3f) | 0x80;
79
- output[offset++] = ((value >> 6) & 0x3f) | 0x80;
80
- }
81
- }
82
- output[offset++] = (value & 0x3f) | 0x80;
83
- }
84
- }
85
- // TextEncoder and TextDecoder are standardized in whatwg encoding:
86
- // https://encoding.spec.whatwg.org/
87
- // and available in all the modern browsers:
88
- // https://caniuse.com/textencoder
89
- // They are available in Node.js since v12 LTS as well:
90
- // https://nodejs.org/api/globals.html#textencoder
91
- const sharedTextEncoder = new TextEncoder();
92
- // This threshold should be determined by benchmarking, which might vary in engines and input data.
93
- // Run `npx ts-node benchmark/encode-string.ts` for details.
94
- const TEXT_ENCODER_THRESHOLD = 50;
95
- function utf8EncodeTE(str, output, outputOffset) {
96
- sharedTextEncoder.encodeInto(str, output.subarray(outputOffset));
97
- }
98
- function utf8Encode(str, output, outputOffset) {
99
- if (str.length > TEXT_ENCODER_THRESHOLD) {
100
- utf8EncodeTE(str, output, outputOffset);
101
- }
102
- else {
103
- utf8EncodeJs(str, output, outputOffset);
104
- }
105
- }
106
- const CHUNK_SIZE = 4096;
107
- function utf8DecodeJs(bytes, inputOffset, byteLength) {
108
- let offset = inputOffset;
109
- const end = offset + byteLength;
110
- const units = [];
111
- let result = "";
112
- while (offset < end) {
113
- const byte1 = bytes[offset++];
114
- if ((byte1 & 0x80) === 0) {
115
- // 1 byte
116
- units.push(byte1);
117
- }
118
- else if ((byte1 & 0xe0) === 0xc0) {
119
- // 2 bytes
120
- const byte2 = bytes[offset++] & 0x3f;
121
- units.push(((byte1 & 0x1f) << 6) | byte2);
122
- }
123
- else if ((byte1 & 0xf0) === 0xe0) {
124
- // 3 bytes
125
- const byte2 = bytes[offset++] & 0x3f;
126
- const byte3 = bytes[offset++] & 0x3f;
127
- units.push(((byte1 & 0x1f) << 12) | (byte2 << 6) | byte3);
128
- }
129
- else if ((byte1 & 0xf8) === 0xf0) {
130
- // 4 bytes
131
- const byte2 = bytes[offset++] & 0x3f;
132
- const byte3 = bytes[offset++] & 0x3f;
133
- const byte4 = bytes[offset++] & 0x3f;
134
- let unit = ((byte1 & 0x07) << 0x12) | (byte2 << 0x0c) | (byte3 << 0x06) | byte4;
135
- if (unit > 0xffff) {
136
- unit -= 0x10000;
137
- units.push(((unit >>> 10) & 0x3ff) | 0xd800);
138
- unit = 0xdc00 | (unit & 0x3ff);
139
- }
140
- units.push(unit);
141
- }
142
- else {
143
- units.push(byte1);
144
- }
145
- if (units.length >= CHUNK_SIZE) {
146
- result += String.fromCharCode(...units);
147
- units.length = 0;
148
- }
149
- }
150
- if (units.length > 0) {
151
- result += String.fromCharCode(...units);
152
- }
153
- return result;
154
- }
155
- const sharedTextDecoder = new TextDecoder();
156
- // This threshold should be determined by benchmarking, which might vary in engines and input data.
157
- // Run `npx ts-node benchmark/decode-string.ts` for details.
158
- const TEXT_DECODER_THRESHOLD = 200;
159
- function utf8DecodeTD(bytes, inputOffset, byteLength) {
160
- const stringBytes = bytes.subarray(inputOffset, inputOffset + byteLength);
161
- return sharedTextDecoder.decode(stringBytes);
162
- }
163
- function utf8Decode(bytes, inputOffset, byteLength) {
164
- if (byteLength > TEXT_DECODER_THRESHOLD) {
165
- return utf8DecodeTD(bytes, inputOffset, byteLength);
166
- }
167
- else {
168
- return utf8DecodeJs(bytes, inputOffset, byteLength);
169
- }
170
- }
171
-
172
- /**
173
- * ExtData is used to handle Extension Types that are not registered to ExtensionCodec.
174
- */
175
- class ExtData {
176
- type;
177
- data;
178
- constructor(type, data) {
179
- this.type = type;
180
- this.data = data;
181
- }
182
- }
183
-
184
- class DecodeError extends Error {
185
- constructor(message) {
186
- super(message);
187
- // fix the prototype chain in a cross-platform way
188
- const proto = Object.create(DecodeError.prototype);
189
- Object.setPrototypeOf(this, proto);
190
- Object.defineProperty(this, "name", {
191
- configurable: true,
192
- enumerable: false,
193
- value: DecodeError.name,
194
- });
195
- }
196
- }
197
-
198
- // Integer Utility
199
- const UINT32_MAX = 4294967295;
200
- // DataView extension to handle int64 / uint64,
201
- // where the actual range is 53-bits integer (a.k.a. safe integer)
202
- function setUint64(view, offset, value) {
203
- const high = value / 4294967296;
204
- const low = value; // high bits are truncated by DataView
205
- view.setUint32(offset, high);
206
- view.setUint32(offset + 4, low);
207
- }
208
- function setInt64(view, offset, value) {
209
- const high = Math.floor(value / 4294967296);
210
- const low = value; // high bits are truncated by DataView
211
- view.setUint32(offset, high);
212
- view.setUint32(offset + 4, low);
213
- }
214
- function getInt64(view, offset) {
215
- const high = view.getInt32(offset);
216
- const low = view.getUint32(offset + 4);
217
- return high * 4294967296 + low;
218
- }
219
- function getUint64(view, offset) {
220
- const high = view.getUint32(offset);
221
- const low = view.getUint32(offset + 4);
222
- return high * 4294967296 + low;
223
- }
224
-
225
- // https://github.com/msgpack/msgpack/blob/master/spec.md#timestamp-extension-type
226
- const EXT_TIMESTAMP = -1;
227
- const TIMESTAMP32_MAX_SEC = 0x100000000 - 1; // 32-bit unsigned int
228
- const TIMESTAMP64_MAX_SEC = 0x400000000 - 1; // 34-bit unsigned int
229
- function encodeTimeSpecToTimestamp({ sec, nsec }) {
230
- if (sec >= 0 && nsec >= 0 && sec <= TIMESTAMP64_MAX_SEC) {
231
- // Here sec >= 0 && nsec >= 0
232
- if (nsec === 0 && sec <= TIMESTAMP32_MAX_SEC) {
233
- // timestamp 32 = { sec32 (unsigned) }
234
- const rv = new Uint8Array(4);
235
- const view = new DataView(rv.buffer);
236
- view.setUint32(0, sec);
237
- return rv;
238
- }
239
- else {
240
- // timestamp 64 = { nsec30 (unsigned), sec34 (unsigned) }
241
- const secHigh = sec / 0x100000000;
242
- const secLow = sec & 0xffffffff;
243
- const rv = new Uint8Array(8);
244
- const view = new DataView(rv.buffer);
245
- // nsec30 | secHigh2
246
- view.setUint32(0, (nsec << 2) | (secHigh & 0x3));
247
- // secLow32
248
- view.setUint32(4, secLow);
249
- return rv;
250
- }
251
- }
252
- else {
253
- // timestamp 96 = { nsec32 (unsigned), sec64 (signed) }
254
- const rv = new Uint8Array(12);
255
- const view = new DataView(rv.buffer);
256
- view.setUint32(0, nsec);
257
- setInt64(view, 4, sec);
258
- return rv;
259
- }
260
- }
261
- function encodeDateToTimeSpec(date) {
262
- const msec = date.getTime();
263
- const sec = Math.floor(msec / 1e3);
264
- const nsec = (msec - sec * 1e3) * 1e6;
265
- // Normalizes { sec, nsec } to ensure nsec is unsigned.
266
- const nsecInSec = Math.floor(nsec / 1e9);
267
- return {
268
- sec: sec + nsecInSec,
269
- nsec: nsec - nsecInSec * 1e9,
270
- };
271
- }
272
- function encodeTimestampExtension(object) {
273
- if (object instanceof Date) {
274
- const timeSpec = encodeDateToTimeSpec(object);
275
- return encodeTimeSpecToTimestamp(timeSpec);
276
- }
277
- else {
278
- return null;
279
- }
280
- }
281
- function decodeTimestampToTimeSpec(data) {
282
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
283
- // data may be 32, 64, or 96 bits
284
- switch (data.byteLength) {
285
- case 4: {
286
- // timestamp 32 = { sec32 }
287
- const sec = view.getUint32(0);
288
- const nsec = 0;
289
- return { sec, nsec };
290
- }
291
- case 8: {
292
- // timestamp 64 = { nsec30, sec34 }
293
- const nsec30AndSecHigh2 = view.getUint32(0);
294
- const secLow32 = view.getUint32(4);
295
- const sec = (nsec30AndSecHigh2 & 0x3) * 0x100000000 + secLow32;
296
- const nsec = nsec30AndSecHigh2 >>> 2;
297
- return { sec, nsec };
298
- }
299
- case 12: {
300
- // timestamp 96 = { nsec32 (unsigned), sec64 (signed) }
301
- const sec = getInt64(view, 4);
302
- const nsec = view.getUint32(0);
303
- return { sec, nsec };
304
- }
305
- default:
306
- throw new DecodeError(`Unrecognized data size for timestamp (expected 4, 8, or 12): ${data.length}`);
307
- }
308
- }
309
- function decodeTimestampExtension(data) {
310
- const timeSpec = decodeTimestampToTimeSpec(data);
311
- return new Date(timeSpec.sec * 1e3 + timeSpec.nsec / 1e6);
312
- }
313
- const timestampExtension = {
314
- type: EXT_TIMESTAMP,
315
- encode: encodeTimestampExtension,
316
- decode: decodeTimestampExtension,
317
- };
318
-
319
- // ExtensionCodec to handle MessagePack extensions
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 = [];
332
- constructor() {
333
- this.register(timestampExtension);
334
- }
335
- register({ type, encode, decode, }) {
336
- if (type >= 0) {
337
- // custom extensions
338
- this.encoders[type] = encode;
339
- this.decoders[type] = decode;
340
- }
341
- else {
342
- // built-in extensions
343
- const index = -1 - type;
344
- this.builtInEncoders[index] = encode;
345
- this.builtInDecoders[index] = decode;
346
- }
347
- }
348
- tryToEncode(object, context) {
349
- // built-in extensions
350
- for (let i = 0; i < this.builtInEncoders.length; i++) {
351
- const encodeExt = this.builtInEncoders[i];
352
- if (encodeExt != null) {
353
- const data = encodeExt(object, context);
354
- if (data != null) {
355
- const type = -1 - i;
356
- return new ExtData(type, data);
357
- }
358
- }
359
- }
360
- // custom extensions
361
- for (let i = 0; i < this.encoders.length; i++) {
362
- const encodeExt = this.encoders[i];
363
- if (encodeExt != null) {
364
- const data = encodeExt(object, context);
365
- if (data != null) {
366
- const type = i;
367
- return new ExtData(type, data);
368
- }
369
- }
370
- }
371
- if (object instanceof ExtData) {
372
- // to keep ExtData as is
373
- return object;
374
- }
375
- return null;
376
- }
377
- decode(data, type, context) {
378
- const decodeExt = type < 0 ? this.builtInDecoders[-1 - type] : this.decoders[type];
379
- if (decodeExt) {
380
- return decodeExt(data, type, context);
381
- }
382
- else {
383
- // decode() does not fail, returns ExtData instead.
384
- return new ExtData(type, data);
385
- }
386
- }
387
- }
388
-
389
- function isArrayBufferLike(buffer) {
390
- return (buffer instanceof ArrayBuffer || (typeof SharedArrayBuffer !== "undefined" && buffer instanceof SharedArrayBuffer));
391
- }
392
- function ensureUint8Array(buffer) {
393
- if (buffer instanceof Uint8Array) {
394
- return buffer;
395
- }
396
- else if (ArrayBuffer.isView(buffer)) {
397
- return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
398
- }
399
- else if (isArrayBufferLike(buffer)) {
400
- return new Uint8Array(buffer);
401
- }
402
- else {
403
- // ArrayLike<number>
404
- return Uint8Array.from(buffer);
405
- }
406
- }
407
-
408
- const DEFAULT_MAX_DEPTH = 100;
409
- const DEFAULT_INITIAL_BUFFER_SIZE = 2048;
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;
424
- constructor(options) {
425
- this.extensionCodec = options?.extensionCodec ?? ExtensionCodec.defaultCodec;
426
- this.context = options?.context; // needs a type assertion because EncoderOptions has no context property when ContextType is undefined
427
- this.useBigInt64 = options?.useBigInt64 ?? false;
428
- this.maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH;
429
- this.initialBufferSize = options?.initialBufferSize ?? DEFAULT_INITIAL_BUFFER_SIZE;
430
- this.sortKeys = options?.sortKeys ?? false;
431
- this.forceFloat32 = options?.forceFloat32 ?? false;
432
- this.ignoreUndefined = options?.ignoreUndefined ?? false;
433
- this.forceIntegerToFloat = options?.forceIntegerToFloat ?? false;
434
- this.pos = 0;
435
- this.view = new DataView(new ArrayBuffer(this.initialBufferSize));
436
- this.bytes = new Uint8Array(this.view.buffer);
437
- }
438
- clone() {
439
- // Because of slightly special argument `context`,
440
- // type assertion is needed.
441
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
442
- return new Encoder({
443
- extensionCodec: this.extensionCodec,
444
- context: this.context,
445
- useBigInt64: this.useBigInt64,
446
- maxDepth: this.maxDepth,
447
- initialBufferSize: this.initialBufferSize,
448
- sortKeys: this.sortKeys,
449
- forceFloat32: this.forceFloat32,
450
- ignoreUndefined: this.ignoreUndefined,
451
- forceIntegerToFloat: this.forceIntegerToFloat,
452
- });
453
- }
454
- reinitializeState() {
455
- this.pos = 0;
456
- }
457
- /**
458
- * This is almost equivalent to {@link Encoder#encode}, but it returns an reference of the encoder's internal buffer and thus much faster than {@link Encoder#encode}.
459
- *
460
- * @returns Encodes the object and returns a shared reference the encoder's internal buffer.
461
- */
462
- encodeSharedRef(object) {
463
- if (this.entered) {
464
- const instance = this.clone();
465
- return instance.encodeSharedRef(object);
466
- }
467
- try {
468
- this.entered = true;
469
- this.reinitializeState();
470
- this.doEncode(object, 1);
471
- return this.bytes.subarray(0, this.pos);
472
- }
473
- finally {
474
- this.entered = false;
475
- }
476
- }
477
- /**
478
- * @returns Encodes the object and returns a copy of the encoder's internal buffer.
479
- */
480
- encode(object) {
481
- if (this.entered) {
482
- const instance = this.clone();
483
- return instance.encode(object);
484
- }
485
- try {
486
- this.entered = true;
487
- this.reinitializeState();
488
- this.doEncode(object, 1);
489
- return this.bytes.slice(0, this.pos);
490
- }
491
- finally {
492
- this.entered = false;
493
- }
494
- }
495
- doEncode(object, depth) {
496
- if (depth > this.maxDepth) {
497
- throw new Error(`Too deep objects in depth ${depth}`);
498
- }
499
- if (object == null) {
500
- this.encodeNil();
501
- }
502
- else if (typeof object === "boolean") {
503
- this.encodeBoolean(object);
504
- }
505
- else if (typeof object === "number") {
506
- if (!this.forceIntegerToFloat) {
507
- this.encodeNumber(object);
508
- }
509
- else {
510
- this.encodeNumberAsFloat(object);
511
- }
512
- }
513
- else if (typeof object === "string") {
514
- this.encodeString(object);
515
- }
516
- else if (this.useBigInt64 && typeof object === "bigint") {
517
- this.encodeBigInt64(object);
518
- }
519
- else {
520
- this.encodeObject(object, depth);
521
- }
522
- }
523
- ensureBufferSizeToWrite(sizeToWrite) {
524
- const requiredSize = this.pos + sizeToWrite;
525
- if (this.view.byteLength < requiredSize) {
526
- this.resizeBuffer(requiredSize * 2);
527
- }
528
- }
529
- resizeBuffer(newSize) {
530
- const newBuffer = new ArrayBuffer(newSize);
531
- const newBytes = new Uint8Array(newBuffer);
532
- const newView = new DataView(newBuffer);
533
- newBytes.set(this.bytes);
534
- this.view = newView;
535
- this.bytes = newBytes;
536
- }
537
- encodeNil() {
538
- this.writeU8(0xc0);
539
- }
540
- encodeBoolean(object) {
541
- if (object === false) {
542
- this.writeU8(0xc2);
543
- }
544
- else {
545
- this.writeU8(0xc3);
546
- }
547
- }
548
- encodeNumber(object) {
549
- if (!this.forceIntegerToFloat && Number.isSafeInteger(object)) {
550
- if (object >= 0) {
551
- if (object < 0x80) {
552
- // positive fixint
553
- this.writeU8(object);
554
- }
555
- else if (object < 0x100) {
556
- // uint 8
557
- this.writeU8(0xcc);
558
- this.writeU8(object);
559
- }
560
- else if (object < 0x10000) {
561
- // uint 16
562
- this.writeU8(0xcd);
563
- this.writeU16(object);
564
- }
565
- else if (object < 0x100000000) {
566
- // uint 32
567
- this.writeU8(0xce);
568
- this.writeU32(object);
569
- }
570
- else if (!this.useBigInt64) {
571
- // uint 64
572
- this.writeU8(0xcf);
573
- this.writeU64(object);
574
- }
575
- else {
576
- this.encodeNumberAsFloat(object);
577
- }
578
- }
579
- else {
580
- if (object >= -32) {
581
- // negative fixint
582
- this.writeU8(0xe0 | (object + 0x20));
583
- }
584
- else if (object >= -128) {
585
- // int 8
586
- this.writeU8(0xd0);
587
- this.writeI8(object);
588
- }
589
- else if (object >= -32768) {
590
- // int 16
591
- this.writeU8(0xd1);
592
- this.writeI16(object);
593
- }
594
- else if (object >= -2147483648) {
595
- // int 32
596
- this.writeU8(0xd2);
597
- this.writeI32(object);
598
- }
599
- else if (!this.useBigInt64) {
600
- // int 64
601
- this.writeU8(0xd3);
602
- this.writeI64(object);
603
- }
604
- else {
605
- this.encodeNumberAsFloat(object);
606
- }
607
- }
608
- }
609
- else {
610
- this.encodeNumberAsFloat(object);
611
- }
612
- }
613
- encodeNumberAsFloat(object) {
614
- if (this.forceFloat32) {
615
- // float 32
616
- this.writeU8(0xca);
617
- this.writeF32(object);
618
- }
619
- else {
620
- // float 64
621
- this.writeU8(0xcb);
622
- this.writeF64(object);
623
- }
624
- }
625
- encodeBigInt64(object) {
626
- if (object >= BigInt(0)) {
627
- // uint 64
628
- this.writeU8(0xcf);
629
- this.writeBigUint64(object);
630
- }
631
- else {
632
- // int 64
633
- this.writeU8(0xd3);
634
- this.writeBigInt64(object);
635
- }
636
- }
637
- writeStringHeader(byteLength) {
638
- if (byteLength < 32) {
639
- // fixstr
640
- this.writeU8(0xa0 + byteLength);
641
- }
642
- else if (byteLength < 0x100) {
643
- // str 8
644
- this.writeU8(0xd9);
645
- this.writeU8(byteLength);
646
- }
647
- else if (byteLength < 0x10000) {
648
- // str 16
649
- this.writeU8(0xda);
650
- this.writeU16(byteLength);
651
- }
652
- else if (byteLength < 0x100000000) {
653
- // str 32
654
- this.writeU8(0xdb);
655
- this.writeU32(byteLength);
656
- }
657
- else {
658
- throw new Error(`Too long string: ${byteLength} bytes in UTF-8`);
659
- }
660
- }
661
- encodeString(object) {
662
- const maxHeaderSize = 1 + 4;
663
- const byteLength = utf8Count(object);
664
- this.ensureBufferSizeToWrite(maxHeaderSize + byteLength);
665
- this.writeStringHeader(byteLength);
666
- utf8Encode(object, this.bytes, this.pos);
667
- this.pos += byteLength;
668
- }
669
- encodeObject(object, depth) {
670
- // try to encode objects with custom codec first of non-primitives
671
- const ext = this.extensionCodec.tryToEncode(object, this.context);
672
- if (ext != null) {
673
- this.encodeExtension(ext);
674
- }
675
- else if (Array.isArray(object)) {
676
- this.encodeArray(object, depth);
677
- }
678
- else if (ArrayBuffer.isView(object)) {
679
- this.encodeBinary(object);
680
- }
681
- else if (typeof object === "object") {
682
- this.encodeMap(object, depth);
683
- }
684
- else {
685
- // symbol, function and other special object come here unless extensionCodec handles them.
686
- throw new Error(`Unrecognized object: ${Object.prototype.toString.apply(object)}`);
687
- }
688
- }
689
- encodeBinary(object) {
690
- const size = object.byteLength;
691
- if (size < 0x100) {
692
- // bin 8
693
- this.writeU8(0xc4);
694
- this.writeU8(size);
695
- }
696
- else if (size < 0x10000) {
697
- // bin 16
698
- this.writeU8(0xc5);
699
- this.writeU16(size);
700
- }
701
- else if (size < 0x100000000) {
702
- // bin 32
703
- this.writeU8(0xc6);
704
- this.writeU32(size);
705
- }
706
- else {
707
- throw new Error(`Too large binary: ${size}`);
708
- }
709
- const bytes = ensureUint8Array(object);
710
- this.writeU8a(bytes);
711
- }
712
- encodeArray(object, depth) {
713
- const size = object.length;
714
- if (size < 16) {
715
- // fixarray
716
- this.writeU8(0x90 + size);
717
- }
718
- else if (size < 0x10000) {
719
- // array 16
720
- this.writeU8(0xdc);
721
- this.writeU16(size);
722
- }
723
- else if (size < 0x100000000) {
724
- // array 32
725
- this.writeU8(0xdd);
726
- this.writeU32(size);
727
- }
728
- else {
729
- throw new Error(`Too large array: ${size}`);
730
- }
731
- for (const item of object) {
732
- this.doEncode(item, depth + 1);
733
- }
734
- }
735
- countWithoutUndefined(object, keys) {
736
- let count = 0;
737
- for (const key of keys) {
738
- if (object[key] !== undefined) {
739
- count++;
740
- }
741
- }
742
- return count;
743
- }
744
- encodeMap(object, depth) {
745
- const keys = Object.keys(object);
746
- if (this.sortKeys) {
747
- keys.sort();
748
- }
749
- const size = this.ignoreUndefined ? this.countWithoutUndefined(object, keys) : keys.length;
750
- if (size < 16) {
751
- // fixmap
752
- this.writeU8(0x80 + size);
753
- }
754
- else if (size < 0x10000) {
755
- // map 16
756
- this.writeU8(0xde);
757
- this.writeU16(size);
758
- }
759
- else if (size < 0x100000000) {
760
- // map 32
761
- this.writeU8(0xdf);
762
- this.writeU32(size);
763
- }
764
- else {
765
- throw new Error(`Too large map object: ${size}`);
766
- }
767
- for (const key of keys) {
768
- const value = object[key];
769
- if (!(this.ignoreUndefined && value === undefined)) {
770
- this.encodeString(key);
771
- this.doEncode(value, depth + 1);
772
- }
773
- }
774
- }
775
- encodeExtension(ext) {
776
- if (typeof ext.data === "function") {
777
- const data = ext.data(this.pos + 6);
778
- const size = data.length;
779
- if (size >= 0x100000000) {
780
- throw new Error(`Too large extension object: ${size}`);
781
- }
782
- this.writeU8(0xc9);
783
- this.writeU32(size);
784
- this.writeI8(ext.type);
785
- this.writeU8a(data);
786
- return;
787
- }
788
- const size = ext.data.length;
789
- if (size === 1) {
790
- // fixext 1
791
- this.writeU8(0xd4);
792
- }
793
- else if (size === 2) {
794
- // fixext 2
795
- this.writeU8(0xd5);
796
- }
797
- else if (size === 4) {
798
- // fixext 4
799
- this.writeU8(0xd6);
800
- }
801
- else if (size === 8) {
802
- // fixext 8
803
- this.writeU8(0xd7);
804
- }
805
- else if (size === 16) {
806
- // fixext 16
807
- this.writeU8(0xd8);
808
- }
809
- else if (size < 0x100) {
810
- // ext 8
811
- this.writeU8(0xc7);
812
- this.writeU8(size);
813
- }
814
- else if (size < 0x10000) {
815
- // ext 16
816
- this.writeU8(0xc8);
817
- this.writeU16(size);
818
- }
819
- else if (size < 0x100000000) {
820
- // ext 32
821
- this.writeU8(0xc9);
822
- this.writeU32(size);
823
- }
824
- else {
825
- throw new Error(`Too large extension object: ${size}`);
826
- }
827
- this.writeI8(ext.type);
828
- this.writeU8a(ext.data);
829
- }
830
- writeU8(value) {
831
- this.ensureBufferSizeToWrite(1);
832
- this.view.setUint8(this.pos, value);
833
- this.pos++;
834
- }
835
- writeU8a(values) {
836
- const size = values.length;
837
- this.ensureBufferSizeToWrite(size);
838
- this.bytes.set(values, this.pos);
839
- this.pos += size;
840
- }
841
- writeI8(value) {
842
- this.ensureBufferSizeToWrite(1);
843
- this.view.setInt8(this.pos, value);
844
- this.pos++;
845
- }
846
- writeU16(value) {
847
- this.ensureBufferSizeToWrite(2);
848
- this.view.setUint16(this.pos, value);
849
- this.pos += 2;
850
- }
851
- writeI16(value) {
852
- this.ensureBufferSizeToWrite(2);
853
- this.view.setInt16(this.pos, value);
854
- this.pos += 2;
855
- }
856
- writeU32(value) {
857
- this.ensureBufferSizeToWrite(4);
858
- this.view.setUint32(this.pos, value);
859
- this.pos += 4;
860
- }
861
- writeI32(value) {
862
- this.ensureBufferSizeToWrite(4);
863
- this.view.setInt32(this.pos, value);
864
- this.pos += 4;
865
- }
866
- writeF32(value) {
867
- this.ensureBufferSizeToWrite(4);
868
- this.view.setFloat32(this.pos, value);
869
- this.pos += 4;
870
- }
871
- writeF64(value) {
872
- this.ensureBufferSizeToWrite(8);
873
- this.view.setFloat64(this.pos, value);
874
- this.pos += 8;
875
- }
876
- writeU64(value) {
877
- this.ensureBufferSizeToWrite(8);
878
- setUint64(this.view, this.pos, value);
879
- this.pos += 8;
880
- }
881
- writeI64(value) {
882
- this.ensureBufferSizeToWrite(8);
883
- setInt64(this.view, this.pos, value);
884
- this.pos += 8;
885
- }
886
- writeBigUint64(value) {
887
- this.ensureBufferSizeToWrite(8);
888
- this.view.setBigUint64(this.pos, value);
889
- this.pos += 8;
890
- }
891
- writeBigInt64(value) {
892
- this.ensureBufferSizeToWrite(8);
893
- this.view.setBigInt64(this.pos, value);
894
- this.pos += 8;
895
- }
896
- }
897
-
898
- /**
899
- * It encodes `value` in the MessagePack format and
900
- * returns a byte buffer.
901
- *
902
- * The returned buffer is a slice of a larger `ArrayBuffer`, so you have to use its `#byteOffset` and `#byteLength` in order to convert it to another typed arrays including NodeJS `Buffer`.
903
- */
904
- function encode(value, options) {
905
- const encoder = new Encoder(options);
906
- return encoder.encodeSharedRef(value);
907
- }
908
-
909
- function prettyByte(byte) {
910
- return `${byte < 0 ? "-" : ""}0x${Math.abs(byte).toString(16).padStart(2, "0")}`;
911
- }
912
-
913
- const DEFAULT_MAX_KEY_LENGTH = 16;
914
- const DEFAULT_MAX_LENGTH_PER_KEY = 16;
915
- class CachedKeyDecoder {
916
- hit = 0;
917
- miss = 0;
918
- caches;
919
- maxKeyLength;
920
- maxLengthPerKey;
921
- constructor(maxKeyLength = DEFAULT_MAX_KEY_LENGTH, maxLengthPerKey = DEFAULT_MAX_LENGTH_PER_KEY) {
922
- this.maxKeyLength = maxKeyLength;
923
- this.maxLengthPerKey = maxLengthPerKey;
924
- // avoid `new Array(N)`, which makes a sparse array,
925
- // because a sparse array is typically slower than a non-sparse array.
926
- this.caches = [];
927
- for (let i = 0; i < this.maxKeyLength; i++) {
928
- this.caches.push([]);
929
- }
930
- }
931
- canBeCached(byteLength) {
932
- return byteLength > 0 && byteLength <= this.maxKeyLength;
933
- }
934
- find(bytes, inputOffset, byteLength) {
935
- const records = this.caches[byteLength - 1];
936
- FIND_CHUNK: for (const record of records) {
937
- const recordBytes = record.bytes;
938
- for (let j = 0; j < byteLength; j++) {
939
- if (recordBytes[j] !== bytes[inputOffset + j]) {
940
- continue FIND_CHUNK;
941
- }
942
- }
943
- return record.str;
944
- }
945
- return null;
946
- }
947
- store(bytes, value) {
948
- const records = this.caches[bytes.length - 1];
949
- const record = { bytes, str: value };
950
- if (records.length >= this.maxLengthPerKey) {
951
- // `records` are full!
952
- // Set `record` to an arbitrary position.
953
- records[(Math.random() * records.length) | 0] = record;
954
- }
955
- else {
956
- records.push(record);
957
- }
958
- }
959
- decode(bytes, inputOffset, byteLength) {
960
- const cachedValue = this.find(bytes, inputOffset, byteLength);
961
- if (cachedValue != null) {
962
- this.hit++;
963
- return cachedValue;
964
- }
965
- this.miss++;
966
- const str = utf8DecodeJs(bytes, inputOffset, byteLength);
967
- // Ensure to copy a slice of bytes because the bytes may be a NodeJS Buffer and Buffer#slice() returns a reference to its internal ArrayBuffer.
968
- const slicedCopyOfBytes = Uint8Array.prototype.slice.call(bytes, inputOffset, inputOffset + byteLength);
969
- this.store(slicedCopyOfBytes, str);
970
- return str;
971
- }
972
- }
973
-
974
- const STATE_ARRAY = "array";
975
- const STATE_MAP_KEY = "map_key";
976
- const STATE_MAP_VALUE = "map_value";
977
- const mapKeyConverter = (key) => {
978
- if (typeof key === "string" || typeof key === "number") {
979
- return key;
980
- }
981
- throw new DecodeError("The type of key must be string or number but " + typeof key);
982
- };
983
- class StackPool {
984
- stack = [];
985
- stackHeadPosition = -1;
986
- get length() {
987
- return this.stackHeadPosition + 1;
988
- }
989
- top() {
990
- return this.stack[this.stackHeadPosition];
991
- }
992
- pushArrayState(size) {
993
- const state = this.getUninitializedStateFromPool();
994
- state.type = STATE_ARRAY;
995
- state.position = 0;
996
- state.size = size;
997
- state.array = new Array(size);
998
- }
999
- pushMapState(size) {
1000
- const state = this.getUninitializedStateFromPool();
1001
- state.type = STATE_MAP_KEY;
1002
- state.readCount = 0;
1003
- state.size = size;
1004
- state.map = {};
1005
- }
1006
- getUninitializedStateFromPool() {
1007
- this.stackHeadPosition++;
1008
- if (this.stackHeadPosition === this.stack.length) {
1009
- const partialState = {
1010
- type: undefined,
1011
- size: 0,
1012
- array: undefined,
1013
- position: 0,
1014
- readCount: 0,
1015
- map: undefined,
1016
- key: null,
1017
- };
1018
- this.stack.push(partialState);
1019
- }
1020
- return this.stack[this.stackHeadPosition];
1021
- }
1022
- release(state) {
1023
- const topStackState = this.stack[this.stackHeadPosition];
1024
- if (topStackState !== state) {
1025
- throw new Error("Invalid stack state. Released state is not on top of the stack.");
1026
- }
1027
- if (state.type === STATE_ARRAY) {
1028
- const partialState = state;
1029
- partialState.size = 0;
1030
- partialState.array = undefined;
1031
- partialState.position = 0;
1032
- partialState.type = undefined;
1033
- }
1034
- if (state.type === STATE_MAP_KEY || state.type === STATE_MAP_VALUE) {
1035
- const partialState = state;
1036
- partialState.size = 0;
1037
- partialState.map = undefined;
1038
- partialState.readCount = 0;
1039
- partialState.type = undefined;
1040
- }
1041
- this.stackHeadPosition--;
1042
- }
1043
- reset() {
1044
- this.stack.length = 0;
1045
- this.stackHeadPosition = -1;
1046
- }
1047
- }
1048
- const HEAD_BYTE_REQUIRED = -1;
1049
- const EMPTY_VIEW = new DataView(new ArrayBuffer(0));
1050
- const EMPTY_BYTES = new Uint8Array(EMPTY_VIEW.buffer);
1051
- try {
1052
- // IE11: The spec says it should throw RangeError,
1053
- // IE11: but in IE11 it throws TypeError.
1054
- EMPTY_VIEW.getInt8(0);
1055
- }
1056
- catch (e) {
1057
- if (!(e instanceof RangeError)) {
1058
- throw new Error("This module is not supported in the current JavaScript engine because DataView does not throw RangeError on out-of-bounds access");
1059
- }
1060
- }
1061
- const MORE_DATA = new RangeError("Insufficient data");
1062
- const sharedCachedKeyDecoder = new CachedKeyDecoder();
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;
1082
- constructor(options) {
1083
- this.extensionCodec = options?.extensionCodec ?? ExtensionCodec.defaultCodec;
1084
- this.context = options?.context; // needs a type assertion because EncoderOptions has no context property when ContextType is undefined
1085
- this.useBigInt64 = options?.useBigInt64 ?? false;
1086
- this.rawStrings = options?.rawStrings ?? false;
1087
- this.maxStrLength = options?.maxStrLength ?? UINT32_MAX;
1088
- this.maxBinLength = options?.maxBinLength ?? UINT32_MAX;
1089
- this.maxArrayLength = options?.maxArrayLength ?? UINT32_MAX;
1090
- this.maxMapLength = options?.maxMapLength ?? UINT32_MAX;
1091
- this.maxExtLength = options?.maxExtLength ?? UINT32_MAX;
1092
- this.keyDecoder = options?.keyDecoder !== undefined ? options.keyDecoder : sharedCachedKeyDecoder;
1093
- this.mapKeyConverter = options?.mapKeyConverter ?? mapKeyConverter;
1094
- }
1095
- clone() {
1096
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
1097
- return new Decoder({
1098
- extensionCodec: this.extensionCodec,
1099
- context: this.context,
1100
- useBigInt64: this.useBigInt64,
1101
- rawStrings: this.rawStrings,
1102
- maxStrLength: this.maxStrLength,
1103
- maxBinLength: this.maxBinLength,
1104
- maxArrayLength: this.maxArrayLength,
1105
- maxMapLength: this.maxMapLength,
1106
- maxExtLength: this.maxExtLength,
1107
- keyDecoder: this.keyDecoder,
1108
- });
1109
- }
1110
- reinitializeState() {
1111
- this.totalPos = 0;
1112
- this.headByte = HEAD_BYTE_REQUIRED;
1113
- this.stack.reset();
1114
- // view, bytes, and pos will be re-initialized in setBuffer()
1115
- }
1116
- setBuffer(buffer) {
1117
- const bytes = ensureUint8Array(buffer);
1118
- this.bytes = bytes;
1119
- this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1120
- this.pos = 0;
1121
- }
1122
- appendBuffer(buffer) {
1123
- if (this.headByte === HEAD_BYTE_REQUIRED && !this.hasRemaining(1)) {
1124
- this.setBuffer(buffer);
1125
- }
1126
- else {
1127
- const remainingData = this.bytes.subarray(this.pos);
1128
- const newData = ensureUint8Array(buffer);
1129
- // concat remainingData + newData
1130
- const newBuffer = new Uint8Array(remainingData.length + newData.length);
1131
- newBuffer.set(remainingData);
1132
- newBuffer.set(newData, remainingData.length);
1133
- this.setBuffer(newBuffer);
1134
- }
1135
- }
1136
- hasRemaining(size) {
1137
- return this.view.byteLength - this.pos >= size;
1138
- }
1139
- createExtraByteError(posToShow) {
1140
- const { view, pos } = this;
1141
- return new RangeError(`Extra ${view.byteLength - pos} of ${view.byteLength} byte(s) found at buffer[${posToShow}]`);
1142
- }
1143
- /**
1144
- * @throws {@link DecodeError}
1145
- * @throws {@link RangeError}
1146
- */
1147
- decode(buffer) {
1148
- if (this.entered) {
1149
- const instance = this.clone();
1150
- return instance.decode(buffer);
1151
- }
1152
- try {
1153
- this.entered = true;
1154
- this.reinitializeState();
1155
- this.setBuffer(buffer);
1156
- const object = this.doDecodeSync();
1157
- if (this.hasRemaining(1)) {
1158
- throw this.createExtraByteError(this.pos);
1159
- }
1160
- return object;
1161
- }
1162
- finally {
1163
- this.entered = false;
1164
- }
1165
- }
1166
- *decodeMulti(buffer) {
1167
- if (this.entered) {
1168
- const instance = this.clone();
1169
- yield* instance.decodeMulti(buffer);
1170
- return;
1171
- }
1172
- try {
1173
- this.entered = true;
1174
- this.reinitializeState();
1175
- this.setBuffer(buffer);
1176
- while (this.hasRemaining(1)) {
1177
- yield this.doDecodeSync();
1178
- }
1179
- }
1180
- finally {
1181
- this.entered = false;
1182
- }
1183
- }
1184
- async decodeAsync(stream) {
1185
- if (this.entered) {
1186
- const instance = this.clone();
1187
- return instance.decodeAsync(stream);
1188
- }
1189
- try {
1190
- this.entered = true;
1191
- let decoded = false;
1192
- let object;
1193
- for await (const buffer of stream) {
1194
- if (decoded) {
1195
- this.entered = false;
1196
- throw this.createExtraByteError(this.totalPos);
1197
- }
1198
- this.appendBuffer(buffer);
1199
- try {
1200
- object = this.doDecodeSync();
1201
- decoded = true;
1202
- }
1203
- catch (e) {
1204
- if (!(e instanceof RangeError)) {
1205
- throw e; // rethrow
1206
- }
1207
- // fallthrough
1208
- }
1209
- this.totalPos += this.pos;
1210
- }
1211
- if (decoded) {
1212
- if (this.hasRemaining(1)) {
1213
- throw this.createExtraByteError(this.totalPos);
1214
- }
1215
- return object;
1216
- }
1217
- const { headByte, pos, totalPos } = this;
1218
- throw new RangeError(`Insufficient data in parsing ${prettyByte(headByte)} at ${totalPos} (${pos} in the current buffer)`);
1219
- }
1220
- finally {
1221
- this.entered = false;
1222
- }
1223
- }
1224
- decodeArrayStream(stream) {
1225
- return this.decodeMultiAsync(stream, true);
1226
- }
1227
- decodeStream(stream) {
1228
- return this.decodeMultiAsync(stream, false);
1229
- }
1230
- async *decodeMultiAsync(stream, isArray) {
1231
- if (this.entered) {
1232
- const instance = this.clone();
1233
- yield* instance.decodeMultiAsync(stream, isArray);
1234
- return;
1235
- }
1236
- try {
1237
- this.entered = true;
1238
- let isArrayHeaderRequired = isArray;
1239
- let arrayItemsLeft = -1;
1240
- for await (const buffer of stream) {
1241
- if (isArray && arrayItemsLeft === 0) {
1242
- throw this.createExtraByteError(this.totalPos);
1243
- }
1244
- this.appendBuffer(buffer);
1245
- if (isArrayHeaderRequired) {
1246
- arrayItemsLeft = this.readArraySize();
1247
- isArrayHeaderRequired = false;
1248
- this.complete();
1249
- }
1250
- try {
1251
- while (true) {
1252
- yield this.doDecodeSync();
1253
- if (--arrayItemsLeft === 0) {
1254
- break;
1255
- }
1256
- }
1257
- }
1258
- catch (e) {
1259
- if (!(e instanceof RangeError)) {
1260
- throw e; // rethrow
1261
- }
1262
- // fallthrough
1263
- }
1264
- this.totalPos += this.pos;
1265
- }
1266
- }
1267
- finally {
1268
- this.entered = false;
1269
- }
1270
- }
1271
- doDecodeSync() {
1272
- DECODE: while (true) {
1273
- const headByte = this.readHeadByte();
1274
- let object;
1275
- if (headByte >= 0xe0) {
1276
- // negative fixint (111x xxxx) 0xe0 - 0xff
1277
- object = headByte - 0x100;
1278
- }
1279
- else if (headByte < 0xc0) {
1280
- if (headByte < 0x80) {
1281
- // positive fixint (0xxx xxxx) 0x00 - 0x7f
1282
- object = headByte;
1283
- }
1284
- else if (headByte < 0x90) {
1285
- // fixmap (1000 xxxx) 0x80 - 0x8f
1286
- const size = headByte - 0x80;
1287
- if (size !== 0) {
1288
- this.pushMapState(size);
1289
- this.complete();
1290
- continue DECODE;
1291
- }
1292
- else {
1293
- object = {};
1294
- }
1295
- }
1296
- else if (headByte < 0xa0) {
1297
- // fixarray (1001 xxxx) 0x90 - 0x9f
1298
- const size = headByte - 0x90;
1299
- if (size !== 0) {
1300
- this.pushArrayState(size);
1301
- this.complete();
1302
- continue DECODE;
1303
- }
1304
- else {
1305
- object = [];
1306
- }
1307
- }
1308
- else {
1309
- // fixstr (101x xxxx) 0xa0 - 0xbf
1310
- const byteLength = headByte - 0xa0;
1311
- object = this.decodeString(byteLength, 0);
1312
- }
1313
- }
1314
- else if (headByte === 0xc0) {
1315
- // nil
1316
- object = null;
1317
- }
1318
- else if (headByte === 0xc2) {
1319
- // false
1320
- object = false;
1321
- }
1322
- else if (headByte === 0xc3) {
1323
- // true
1324
- object = true;
1325
- }
1326
- else if (headByte === 0xca) {
1327
- // float 32
1328
- object = this.readF32();
1329
- }
1330
- else if (headByte === 0xcb) {
1331
- // float 64
1332
- object = this.readF64();
1333
- }
1334
- else if (headByte === 0xcc) {
1335
- // uint 8
1336
- object = this.readU8();
1337
- }
1338
- else if (headByte === 0xcd) {
1339
- // uint 16
1340
- object = this.readU16();
1341
- }
1342
- else if (headByte === 0xce) {
1343
- // uint 32
1344
- object = this.readU32();
1345
- }
1346
- else if (headByte === 0xcf) {
1347
- // uint 64
1348
- if (this.useBigInt64) {
1349
- object = this.readU64AsBigInt();
1350
- }
1351
- else {
1352
- object = this.readU64();
1353
- }
1354
- }
1355
- else if (headByte === 0xd0) {
1356
- // int 8
1357
- object = this.readI8();
1358
- }
1359
- else if (headByte === 0xd1) {
1360
- // int 16
1361
- object = this.readI16();
1362
- }
1363
- else if (headByte === 0xd2) {
1364
- // int 32
1365
- object = this.readI32();
1366
- }
1367
- else if (headByte === 0xd3) {
1368
- // int 64
1369
- if (this.useBigInt64) {
1370
- object = this.readI64AsBigInt();
1371
- }
1372
- else {
1373
- object = this.readI64();
1374
- }
1375
- }
1376
- else if (headByte === 0xd9) {
1377
- // str 8
1378
- const byteLength = this.lookU8();
1379
- object = this.decodeString(byteLength, 1);
1380
- }
1381
- else if (headByte === 0xda) {
1382
- // str 16
1383
- const byteLength = this.lookU16();
1384
- object = this.decodeString(byteLength, 2);
1385
- }
1386
- else if (headByte === 0xdb) {
1387
- // str 32
1388
- const byteLength = this.lookU32();
1389
- object = this.decodeString(byteLength, 4);
1390
- }
1391
- else if (headByte === 0xdc) {
1392
- // array 16
1393
- const size = this.readU16();
1394
- if (size !== 0) {
1395
- this.pushArrayState(size);
1396
- this.complete();
1397
- continue DECODE;
1398
- }
1399
- else {
1400
- object = [];
1401
- }
1402
- }
1403
- else if (headByte === 0xdd) {
1404
- // array 32
1405
- const size = this.readU32();
1406
- if (size !== 0) {
1407
- this.pushArrayState(size);
1408
- this.complete();
1409
- continue DECODE;
1410
- }
1411
- else {
1412
- object = [];
1413
- }
1414
- }
1415
- else if (headByte === 0xde) {
1416
- // map 16
1417
- const size = this.readU16();
1418
- if (size !== 0) {
1419
- this.pushMapState(size);
1420
- this.complete();
1421
- continue DECODE;
1422
- }
1423
- else {
1424
- object = {};
1425
- }
1426
- }
1427
- else if (headByte === 0xdf) {
1428
- // map 32
1429
- const size = this.readU32();
1430
- if (size !== 0) {
1431
- this.pushMapState(size);
1432
- this.complete();
1433
- continue DECODE;
1434
- }
1435
- else {
1436
- object = {};
1437
- }
1438
- }
1439
- else if (headByte === 0xc4) {
1440
- // bin 8
1441
- const size = this.lookU8();
1442
- object = this.decodeBinary(size, 1);
1443
- }
1444
- else if (headByte === 0xc5) {
1445
- // bin 16
1446
- const size = this.lookU16();
1447
- object = this.decodeBinary(size, 2);
1448
- }
1449
- else if (headByte === 0xc6) {
1450
- // bin 32
1451
- const size = this.lookU32();
1452
- object = this.decodeBinary(size, 4);
1453
- }
1454
- else if (headByte === 0xd4) {
1455
- // fixext 1
1456
- object = this.decodeExtension(1, 0);
1457
- }
1458
- else if (headByte === 0xd5) {
1459
- // fixext 2
1460
- object = this.decodeExtension(2, 0);
1461
- }
1462
- else if (headByte === 0xd6) {
1463
- // fixext 4
1464
- object = this.decodeExtension(4, 0);
1465
- }
1466
- else if (headByte === 0xd7) {
1467
- // fixext 8
1468
- object = this.decodeExtension(8, 0);
1469
- }
1470
- else if (headByte === 0xd8) {
1471
- // fixext 16
1472
- object = this.decodeExtension(16, 0);
1473
- }
1474
- else if (headByte === 0xc7) {
1475
- // ext 8
1476
- const size = this.lookU8();
1477
- object = this.decodeExtension(size, 1);
1478
- }
1479
- else if (headByte === 0xc8) {
1480
- // ext 16
1481
- const size = this.lookU16();
1482
- object = this.decodeExtension(size, 2);
1483
- }
1484
- else if (headByte === 0xc9) {
1485
- // ext 32
1486
- const size = this.lookU32();
1487
- object = this.decodeExtension(size, 4);
1488
- }
1489
- else {
1490
- throw new DecodeError(`Unrecognized type byte: ${prettyByte(headByte)}`);
1491
- }
1492
- this.complete();
1493
- const stack = this.stack;
1494
- while (stack.length > 0) {
1495
- // arrays and maps
1496
- const state = stack.top();
1497
- if (state.type === STATE_ARRAY) {
1498
- state.array[state.position] = object;
1499
- state.position++;
1500
- if (state.position === state.size) {
1501
- object = state.array;
1502
- stack.release(state);
1503
- }
1504
- else {
1505
- continue DECODE;
1506
- }
1507
- }
1508
- else if (state.type === STATE_MAP_KEY) {
1509
- if (object === "__proto__") {
1510
- throw new DecodeError("The key __proto__ is not allowed");
1511
- }
1512
- state.key = this.mapKeyConverter(object);
1513
- state.type = STATE_MAP_VALUE;
1514
- continue DECODE;
1515
- }
1516
- else {
1517
- // it must be `state.type === State.MAP_VALUE` here
1518
- state.map[state.key] = object;
1519
- state.readCount++;
1520
- if (state.readCount === state.size) {
1521
- object = state.map;
1522
- stack.release(state);
1523
- }
1524
- else {
1525
- state.key = null;
1526
- state.type = STATE_MAP_KEY;
1527
- continue DECODE;
1528
- }
1529
- }
1530
- }
1531
- return object;
1532
- }
1533
- }
1534
- readHeadByte() {
1535
- if (this.headByte === HEAD_BYTE_REQUIRED) {
1536
- this.headByte = this.readU8();
1537
- // console.log("headByte", prettyByte(this.headByte));
1538
- }
1539
- return this.headByte;
1540
- }
1541
- complete() {
1542
- this.headByte = HEAD_BYTE_REQUIRED;
1543
- }
1544
- readArraySize() {
1545
- const headByte = this.readHeadByte();
1546
- switch (headByte) {
1547
- case 0xdc:
1548
- return this.readU16();
1549
- case 0xdd:
1550
- return this.readU32();
1551
- default: {
1552
- if (headByte < 0xa0) {
1553
- return headByte - 0x90;
1554
- }
1555
- else {
1556
- throw new DecodeError(`Unrecognized array type byte: ${prettyByte(headByte)}`);
1557
- }
1558
- }
1559
- }
1560
- }
1561
- pushMapState(size) {
1562
- if (size > this.maxMapLength) {
1563
- throw new DecodeError(`Max length exceeded: map length (${size}) > maxMapLengthLength (${this.maxMapLength})`);
1564
- }
1565
- this.stack.pushMapState(size);
1566
- }
1567
- pushArrayState(size) {
1568
- if (size > this.maxArrayLength) {
1569
- throw new DecodeError(`Max length exceeded: array length (${size}) > maxArrayLength (${this.maxArrayLength})`);
1570
- }
1571
- this.stack.pushArrayState(size);
1572
- }
1573
- decodeString(byteLength, headerOffset) {
1574
- if (!this.rawStrings || this.stateIsMapKey()) {
1575
- return this.decodeUtf8String(byteLength, headerOffset);
1576
- }
1577
- return this.decodeBinary(byteLength, headerOffset);
1578
- }
1579
- /**
1580
- * @throws {@link RangeError}
1581
- */
1582
- decodeUtf8String(byteLength, headerOffset) {
1583
- if (byteLength > this.maxStrLength) {
1584
- throw new DecodeError(`Max length exceeded: UTF-8 byte length (${byteLength}) > maxStrLength (${this.maxStrLength})`);
1585
- }
1586
- if (this.bytes.byteLength < this.pos + headerOffset + byteLength) {
1587
- throw MORE_DATA;
1588
- }
1589
- const offset = this.pos + headerOffset;
1590
- let object;
1591
- if (this.stateIsMapKey() && this.keyDecoder?.canBeCached(byteLength)) {
1592
- object = this.keyDecoder.decode(this.bytes, offset, byteLength);
1593
- }
1594
- else {
1595
- object = utf8Decode(this.bytes, offset, byteLength);
1596
- }
1597
- this.pos += headerOffset + byteLength;
1598
- return object;
1599
- }
1600
- stateIsMapKey() {
1601
- if (this.stack.length > 0) {
1602
- const state = this.stack.top();
1603
- return state.type === STATE_MAP_KEY;
1604
- }
1605
- return false;
1606
- }
1607
- /**
1608
- * @throws {@link RangeError}
1609
- */
1610
- decodeBinary(byteLength, headOffset) {
1611
- if (byteLength > this.maxBinLength) {
1612
- throw new DecodeError(`Max length exceeded: bin length (${byteLength}) > maxBinLength (${this.maxBinLength})`);
1613
- }
1614
- if (!this.hasRemaining(byteLength + headOffset)) {
1615
- throw MORE_DATA;
1616
- }
1617
- const offset = this.pos + headOffset;
1618
- const object = this.bytes.subarray(offset, offset + byteLength);
1619
- this.pos += headOffset + byteLength;
1620
- return object;
1621
- }
1622
- decodeExtension(size, headOffset) {
1623
- if (size > this.maxExtLength) {
1624
- throw new DecodeError(`Max length exceeded: ext length (${size}) > maxExtLength (${this.maxExtLength})`);
1625
- }
1626
- const extType = this.view.getInt8(this.pos + headOffset);
1627
- const data = this.decodeBinary(size, headOffset + 1 /* extType */);
1628
- return this.extensionCodec.decode(data, extType, this.context);
1629
- }
1630
- lookU8() {
1631
- return this.view.getUint8(this.pos);
1632
- }
1633
- lookU16() {
1634
- return this.view.getUint16(this.pos);
1635
- }
1636
- lookU32() {
1637
- return this.view.getUint32(this.pos);
1638
- }
1639
- readU8() {
1640
- const value = this.view.getUint8(this.pos);
1641
- this.pos++;
1642
- return value;
1643
- }
1644
- readI8() {
1645
- const value = this.view.getInt8(this.pos);
1646
- this.pos++;
1647
- return value;
1648
- }
1649
- readU16() {
1650
- const value = this.view.getUint16(this.pos);
1651
- this.pos += 2;
1652
- return value;
1653
- }
1654
- readI16() {
1655
- const value = this.view.getInt16(this.pos);
1656
- this.pos += 2;
1657
- return value;
1658
- }
1659
- readU32() {
1660
- const value = this.view.getUint32(this.pos);
1661
- this.pos += 4;
1662
- return value;
1663
- }
1664
- readI32() {
1665
- const value = this.view.getInt32(this.pos);
1666
- this.pos += 4;
1667
- return value;
1668
- }
1669
- readU64() {
1670
- const value = getUint64(this.view, this.pos);
1671
- this.pos += 8;
1672
- return value;
1673
- }
1674
- readI64() {
1675
- const value = getInt64(this.view, this.pos);
1676
- this.pos += 8;
1677
- return value;
1678
- }
1679
- readU64AsBigInt() {
1680
- const value = this.view.getBigUint64(this.pos);
1681
- this.pos += 8;
1682
- return value;
1683
- }
1684
- readI64AsBigInt() {
1685
- const value = this.view.getBigInt64(this.pos);
1686
- this.pos += 8;
1687
- return value;
1688
- }
1689
- readF32() {
1690
- const value = this.view.getFloat32(this.pos);
1691
- this.pos += 4;
1692
- return value;
1693
- }
1694
- readF64() {
1695
- const value = this.view.getFloat64(this.pos);
1696
- this.pos += 8;
1697
- return value;
1698
- }
1699
- }
1700
-
1701
- /**
1702
- * It decodes a single MessagePack object in a buffer.
1703
- *
1704
- * This is a synchronous decoding function.
1705
- * See other variants for asynchronous decoding: {@link decodeAsync}, {@link decodeMultiStream}, or {@link decodeArrayStream}.
1706
- *
1707
- * @throws {@link RangeError} if the buffer is incomplete, including the case where the buffer is empty.
1708
- * @throws {@link DecodeError} if the buffer contains invalid data.
1709
- */
1710
- function decode(buffer, options) {
1711
- const decoder = new Decoder(options);
1712
- return decoder.decode(buffer);
1713
- }
1714
-
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;
1722
-
1723
- const CONSOLE_PREFIX = "PlaySocket CRDT manager: ";
1724
-
1725
- /**
1726
- * CRDT Manager
1727
- * Custom conflict-free replicated data type system with vector clocks
1728
- */
1729
- class CRDTManager {
1730
- // Storage
1731
- #replicaId;
1732
- #keyOperations = new Map();
1733
- #vectorClock = new Map();
1734
-
1735
- #propertyStore = {}; // Current local values as key/value store object
1736
- #lastPropertyStore = {}; // Last property store to compare against
1737
-
1738
- // Local Garbage Collection
1739
- #lastGCCheck = 0;
1740
- #opUuidTimestamp = new Map(); // Map every operation to a timestamp for garbage collection
1741
-
1742
- // Debug
1743
- #debug = false;
1744
-
1745
- /**
1746
- * Create a new instance
1747
- * @param {boolean} [debug=false] - Enable debug logging
1748
- */
1749
- constructor(debug) {
1750
- if (debug) this.#debug = true;
1751
- this.#replicaId = crypto.randomUUID();
1752
- this.#vectorClock.set(this.#replicaId, 0);
1753
- }
1754
-
1755
- /**
1756
- * Import the entire state of the CRDT manager (this overwrites the old state)
1757
- * @param {object} state - Full state of the CRDT manager
1758
- */
1759
- importState(state) {
1760
- try {
1761
- const { keyOperations, vectorClock } = state;
1762
- if (this.#debug) console.log(CONSOLE_PREFIX + "Importing state:", state);
1763
-
1764
- // Resets
1765
- this.#opUuidTimestamp.clear();
1766
- this.#propertyStore = {};
1767
- this.#lastPropertyStore = {};
1768
-
1769
- this.#keyOperations = new Map(keyOperations); // Rebuild the operations map
1770
- this.#vectorClock = new Map(vectorClock); // Rebuild the vector clock map
1771
- 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
1772
-
1773
- // Map all operation uuids to current timestamp
1774
- this.#keyOperations.forEach((value, _key) => {
1775
- value?.forEach((operation) => {
1776
- this.#opUuidTimestamp.set(operation?.uuid, Date.now());
1777
- });
1778
- });
1779
-
1780
- // Process each key to update local values
1781
- for (const key of this.#keyOperations.keys()) this.#processLocalProperty(key);
1782
-
1783
- } catch (error) {
1784
- console.error(CONSOLE_PREFIX + "Failed to import state:", error);
1785
- }
1786
- }
1787
-
1788
- /**
1789
- * Import property update
1790
- * @param {object} data - Property update data
1791
- */
1792
- importPropertyUpdate(data) {
1793
- try {
1794
- const { key, operation: rawOperation, vectorClock } = data;
1795
- const operation = this.#sanitizeValue(rawOperation);
1796
- if (this.#debug) console.log(CONSOLE_PREFIX + "Importing update:", data); // Debug
1797
-
1798
- // Check key limit to afeguard against too many keys
1799
- if (this.#keyOperations.size >= 100 && !this.#keyOperations.has(key)) throw new Error('Key limit exceeded!');
1800
-
1801
- // Get COPY of current ops (or empty array if none yet)
1802
- const currentOps = [...(this.#keyOperations.get(key) || [])];
1803
- // Merge vector clocks (always take max value)
1804
- for (const [id, counter] of vectorClock) {
1805
- if (!this.#vectorClock.has(id) || this.#vectorClock.get(id) < counter) {
1806
- this.#vectorClock.set(id, counter);
1807
- }
1808
- }
1809
-
1810
- // Safeguard against massive vector clocks
1811
- if (this.#vectorClock.size > 1000) this.#vectorClock = new Map([...this.#vectorClock].slice(-100));
1812
-
1813
- // Add new operation if it's not already added
1814
- const existingUuids = new Set(currentOps.map(op => op.uuid));
1815
- if (operation.uuid && !existingUuids.has(operation.uuid)) {
1816
- currentOps.push({ ...operation }); // Add operation
1817
- this.#opUuidTimestamp.set(operation.uuid, Date.now()); // Add timestamp for gc
1818
- }
1819
-
1820
- // Sort, update operations & local value
1821
- this.#keyOperations.set(key, this.#sortByVectorClock(currentOps));
1822
- this.#processLocalProperty(key);
1823
- this.#checkGarbageCollection();
1824
-
1825
- } catch (error) {
1826
- console.error(CONSOLE_PREFIX + "Failed to import property update:", error);
1827
- }
1828
- }
1829
-
1830
- /**
1831
- * Update a property
1832
- * @param {string} key - Storage key
1833
- * @param {string} type - Operation type
1834
- * @param {*} value - New value, or value to match
1835
- * @param {*} updateValue - New value for 'matching' operations
1836
- * @returns {object | undefined} - Returns the property update
1837
- */
1838
- updateProperty(key, type, value, updateValue) {
1839
- try {
1840
- // Sanitize inputs
1841
- value = this.#sanitizeValue(value);
1842
- updateValue = this.#sanitizeValue(updateValue);
1843
-
1844
- // Debug log
1845
- if (this.#debug) console.log(CONSOLE_PREFIX + `Updating property with key ${key}, type ${type}, value ${value} and updateValue ${updateValue}.`);
1846
-
1847
- // Increment vector clock
1848
- const counter = this.#vectorClock.get(this.#replicaId) || 0;
1849
- this.#vectorClock.set(this.#replicaId, counter + 1);
1850
-
1851
- // Assign shallow COPY of currennt ops or fall back to empty array
1852
- const currentOps = [...(this.#keyOperations.get(key) || [])];
1853
-
1854
- // Add operation
1855
- const newOp = this.#createOperation({ type, value, updateValue }, Array.from(this.#vectorClock.entries()));
1856
- currentOps.push(newOp);
1857
- this.#keyOperations.set(key, currentOps); // Update the operations (no need to sort via vector clock since local updates are always the latest)
1858
- this.#processLocalProperty(key); // Process local value
1859
- this.#checkGarbageCollection();
1860
-
1861
- // Return the property update with the new operation (this can be imported using importPropertyUpdate)
1862
- return {
1863
- key,
1864
- operation: { ...newOp },
1865
- vectorClock: Array.from(this.#vectorClock.entries())
1866
- };
1867
-
1868
- } catch (error) {
1869
- console.error(CONSOLE_PREFIX + `Failed to add operation for key "${key}":`, error);
1870
- }
1871
- }
1872
-
1873
- /**
1874
- * Check if garbage collected can be performed & run it
1875
- */
1876
- #checkGarbageCollection() {
1877
- const MIN_GC_DELAY = 1000; // Minimum 1s delay between garbage collection runs
1878
-
1879
- try {
1880
- if (Date.now() - this.#lastGCCheck < MIN_GC_DELAY) return;
1881
- this.#lastGCCheck = Date.now();
1882
-
1883
- for (const [key, operations] of this.#keyOperations.entries()) {
1884
- if (operations?.length < 5) continue; // Min op amount per key for garbage collection to run (otherwise not worth it)
1885
-
1886
- let retainCount = operations.length;
1887
- operations.forEach((op, index) => {
1888
- // Count how many ops, from last to latest, are older than the min-age in a row
1889
- if (op.uuid && this.#opUuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuidTimestamp.get(op.uuid)) > HEARTBEAT_INTERVAL) {
1890
- const removeCount = index + 1;
1891
- retainCount = operations.length - removeCount;
1892
- this.#opUuidTimestamp.delete(op.uuid); // Remove from timestamps if set to be deleted
1893
- }
1894
- });
1895
-
1896
- if (retainCount < operations.length) {
1897
- if (this.#debug) console.log(CONSOLE_PREFIX + `Running garbage collection for key ${key} with current operations:`, operations);
1898
- const retainOps = operations.slice(-retainCount); // Newest ops
1899
- const removeOps = operations.slice(0, -retainCount); // Oldest ops (start = idx 0, end = retainCount counted from right side)
1900
- const baselineVectorClock = removeOps[removeOps.length - 1]?.vectorClock || []; // Use the vector clock from the last operation that we remove/overwrite
1901
-
1902
- // Calculate the value at the point where retained operations start
1903
- let baselineValue = null;
1904
- for (const op of removeOps) {
1905
- if (!op.data) continue;
1906
- baselineValue = this.#handleOperation(
1907
- baselineValue,
1908
- op.data.type,
1909
- op.data.value,
1910
- op.data.updateValue
1911
- );
1912
- }
1913
-
1914
- // Create a compact operation with baseline value and appropriate vector clock
1915
- const compactOp = this.#createOperation({ type: "set", value: baselineValue }, baselineVectorClock);
1916
-
1917
- // Debug log
1918
- if (this.#debug) console.log(CONSOLE_PREFIX + "Operations after garbage collection for this key:", [compactOp, ...retainOps]);
1919
-
1920
- // Combine compact op with retained operations
1921
- this.#keyOperations.set(key, [compactOp, ...retainOps]);
1922
- }
1923
- }
1924
- } catch (error) {
1925
- console.error(CONSOLE_PREFIX + "Error running garbage collection:", error);
1926
- }
1927
- }
1928
-
1929
- /**
1930
- * Create an operation object
1931
- * @param {object} data - Operation data, consists of type, value and updateValue
1932
- * @param {Array} vectorClock - Vector clock of the replica/client
1933
- * @returns {object} - Operation object
1934
- */
1935
- #createOperation(data, vectorClock) {
1936
- const newOperation = {
1937
- data,
1938
- vectorClock,
1939
- source: this.#replicaId,
1940
- uuid: crypto.randomUUID()
1941
- };
1942
- this.#opUuidTimestamp.set(newOperation.uuid, Date.now());
1943
- return newOperation;
1944
- }
1945
-
1946
- /**
1947
- * Process a property's value by applying all operations and set it in the property store
1948
- * @param {string} key - Storage key
1949
- */
1950
- #processLocalProperty(key) {
1951
- try {
1952
- const ops = this.#keyOperations.get(key);
1953
- if (!ops?.length) return;
1954
-
1955
- // Apply all operations in order
1956
- let value = null;
1957
-
1958
- for (const op of ops) {
1959
- if (!op.data) continue;
1960
- value = this.#handleOperation(
1961
- value,
1962
- op.data.type,
1963
- op.data.value,
1964
- op.data.updateValue
1965
- );
1966
- }
1967
-
1968
- this.#propertyStore[key] = value; // Save locally
1969
- } catch (error) {
1970
- console.error(CONSOLE_PREFIX + `Failed to process property for key ${key}:`, error);
1971
- }
1972
- }
1973
-
1974
- /**
1975
- * Sort operations by vector clock (causal order)
1976
- * @param {Array} operations - Full operations that include vector clocks, data and a source
1977
- * @returns {Array} - Sorted operations
1978
- */
1979
- #sortByVectorClock(operations) {
1980
- return [...operations].sort((a, b) => {
1981
- const clockA = new Map(a.vectorClock || []);
1982
- const clockB = new Map(b.vectorClock || []);
1983
-
1984
- // First sort by causal relationship
1985
- let aGreater = false, bGreater = false;
1986
-
1987
- const allIds = new Set([...clockA.keys(), ...clockB.keys()]);
1988
- for (const id of allIds) {
1989
- const countA = clockA.get(id) || 0;
1990
- const countB = clockB.get(id) || 0;
1991
-
1992
- if (countA > countB) aGreater = true;
1993
- if (countA < countB) bGreater = true;
1994
- }
1995
-
1996
- // Determine causal relationship (if one is greater, and the other is smaller, it's clear)
1997
- if (aGreater && !bGreater) return 1; // a happens after b
1998
- if (!aGreater && bGreater) return -1; // a happens before b
1999
-
2000
- // Concurrent (unclear): Compare highest counter in each (sort by which one is ahead)
2001
- const maxA = Math.max(...Array.from(clockA.values()), 0);
2002
- const maxB = Math.max(...Array.from(clockB.values()), 0);
2003
- if (maxA !== maxB) return maxA - maxB;
2004
-
2005
- // Final tiebreak: Sort by ID of Replica that actually generated the operation
2006
- return a.source.localeCompare(b.source);
2007
- });
2008
- }
2009
-
2010
- /**
2011
- * Handle an operation
2012
- * @param {*} curValue - Current value of the storage key
2013
- * @param {string} type - Operation type
2014
- * @param {*} value - New value, or value to match
2015
- * @param {*} [updateValue] - New value for 'matching' operations
2016
- * @returns {*} - Value after the operation
2017
- */
2018
- #handleOperation(curValue, type, value, updateValue) {
2019
- try {
2020
- // Deep copy to avoid reference issues in case value is or contains object(s)
2021
- curValue = structuredClone(curValue);
2022
-
2023
- // Set operation
2024
- if (type === "set") return value;
2025
-
2026
- // Array operations
2027
- if (type.startsWith('array')) {
2028
- if (!Array.isArray(curValue)) curValue = []; // Auto-convert to array if current value isn't one
2029
-
2030
- // Comparison function
2031
- const isObject = typeof value === 'object' && value !== null;
2032
- const compare = (item) => {
2033
- if (isObject && typeof item === 'object' && item !== null) return JSON.stringify(item) === JSON.stringify(value); // Deep comparison
2034
- return item === value; // Value comparison
2035
- };
2036
-
2037
- switch (type) {
2038
- case 'array-add':
2039
- curValue.push(value);
2040
- break;
2041
- case 'array-add-unique':
2042
- if (!curValue.some(compare)) curValue.push(value);
2043
- break;
2044
- case 'array-remove-matching':
2045
- curValue = curValue.filter(item => !compare(item));
2046
- break;
2047
- case 'array-update-matching': {
2048
- const index = curValue.findIndex(compare);
2049
- if (index !== -1) curValue[index] = updateValue;
2050
- break;
2051
- }
2052
- }
2053
- }
2054
-
2055
- return curValue;
2056
-
2057
- } catch (error) {
2058
- console.error(CONSOLE_PREFIX + `Failed to handle operation with curValue ${curValue}:`, error);
2059
- return curValue;
2060
- }
2061
- }
2062
-
2063
- /**
2064
- * Remove HTML to prevent XSS and enforce size limits
2065
- * @param {*} value - Value to sanitize
2066
- * @returns {*} - Sanitized value
2067
- */
2068
- #sanitizeValue(value) {
2069
- // Check total serialized size
2070
- const jsonString = JSON.stringify(value);
2071
- if (jsonString?.length > 50000) throw new Error('Value too large!'); // 50KB limit
2072
-
2073
- if (typeof value === 'string') return (value.includes('<') || value.includes('>')) ? value.replace(/[<>]/g, '') : value;
2074
- if (Array.isArray(value)) return value.map(item => this.#sanitizeValue(item));
2075
- if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, this.#sanitizeValue(v)]));
2076
- return value;
2077
- }
2078
-
2079
- /**
2080
- * Get property store
2081
- * @returns {Record<string, any>} - Object with key value pairs
2082
- */
2083
- get propertyStore() {
2084
- try {
2085
- return structuredClone(this.#propertyStore); // Return deep clone
2086
- } catch {
2087
- console.error(CONSOLE_PREFIX + "Failed to parse property store");
2088
- return {}
2089
- }
2090
- }
2091
-
2092
- /**
2093
- * Check for changes in the local property store
2094
- * @returns {boolean} - Check if any values changed going from the last to the current property update
2095
- */
2096
- get didPropertiesChange() {
2097
- try {
2098
- if (JSON.stringify(this.#propertyStore) !== JSON.stringify(this.#lastPropertyStore)) {
2099
- this.#lastPropertyStore = structuredClone(this.#propertyStore);
2100
- return true;
2101
- }
2102
- } catch {
2103
- console.error(CONSOLE_PREFIX + "Failed to stringify and compare property stores");
2104
- }
2105
- return false;
2106
- }
2107
-
2108
- /**
2109
- * Export state (can be imported using importState, converts the maps to arrays for serialization)
2110
- * @returns {object} - State
2111
- */
2112
- get state() {
2113
- return {
2114
- keyOperations: [...this.#keyOperations.entries()],
2115
- vectorClock: [...this.#vectorClock.entries()]
2116
- };
2117
- }
2118
- }
2119
-
2120
- /* eslint-disable jsdoc/require-returns */
2121
-
2122
- const MAX_ROOM_SIZE = 500;
2123
- const RECONNECT_GRACE_PERIOD = 5000; // Exported for use in tests
2124
-
2125
- /**
2126
- * @typedef {import('node:http').Server} HttpServer
2127
- * @typedef {import('ws').WebSocket} WebSocket
2128
- */
2129
-
2130
- /**
2131
- * PlaySocket Server
2132
- */
2133
- class PlaySocketServer {
2134
- #server;
2135
- #ownsServer = false;
2136
- #rateLimitMaxPoints;
2137
- #wss;
2138
- #clients = new Map(); // ClientId -> WebSocket instance
2139
- #rooms = {};
2140
- #clientRooms = new Map(); // ClientId -> RoomId
2141
- #rateLimits = new Map(); // Rate limiting storage
2142
- #callbacks = new Map(); // Event -> [callback functions]
2143
- #heartbeatInterval;
2144
- #pendingDisconnects = new Map(); // ClientId -> {timeout, roomId}
2145
- #clientTokens = new Map(); // ClientId -> Token
2146
- #roomVersions = new Map(); // RoomId -> Version
2147
-
2148
- // Debug
2149
- #debug = false;
2150
-
2151
- /**
2152
- * Create a new PlaySocketServer instance
2153
- * @param {object} options - Server configuration options
2154
- * @param {HttpServer} [options.server] - Existing http server
2155
- * @param {number} [options.port=3000] - Port to listen on (if no server provided)
2156
- * @param {string} [options.path='/'] - WebSocket endpoint path
2157
- * @param {boolean} [options.debug=false] - Enable debug logging
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
2160
- */
2161
- constructor(options = {}) {
2162
- const { server, port = 3000, path = '/', debug = false, rateLimit = 20, verifyClient } = options;
2163
-
2164
- if (debug) this.#debug = true; // Enable extra logging
2165
-
2166
- this.#rateLimitMaxPoints = rateLimit; // Set rate limit
2167
-
2168
- // Handle server creation / usage
2169
- if (server) {
2170
- this.#server = server; // Use provided HTTP server
2171
- } else {
2172
- this.#ownsServer = true;
2173
- this.#server = createServer(); // Create HTTP server and start it
2174
- this.#server.listen(port, () => {
2175
- console.log(`PlaySocket server running on port ${port}.`);
2176
- });
2177
- }
2178
-
2179
- // Create WebSocket server
2180
- this.#wss = new WebSocketServer({
2181
- server: this.#server,
2182
- path,
2183
- ...(verifyClient && { verifyClient })
2184
- });
2185
-
2186
- // Set up ws event handlers
2187
- this.#wss.on('connection', ws => {
2188
- ws.connectionId = crypto.randomUUID();
2189
- ws.isAlive = true;
2190
- ws.on('pong', () => { ws.isAlive = true; });
2191
- ws.on('message', msg => this.#handleMessage(ws, msg));
2192
- ws.on('close', () => this.#handleDisconnection(ws));
2193
- ws.on('error', (error) => {
2194
- console.error(`PlaySocket WebSocket connection error for client ${ws.clientId || 'unknown'}:`, error); // Catch conn errors
2195
- });
2196
- });
2197
-
2198
- // Log & catch server-level errors
2199
- this.#wss.on('error', (error) => {
2200
- console.error('PlaySocket WebSocket server error:', error);
2201
- });
2202
-
2203
- // Start heartbeat
2204
- this.#heartbeatInterval = setInterval(() => {
2205
- this.#wss.clients.forEach(ws => {
2206
- if (!ws.isAlive) return ws.terminate();
2207
- ws.isAlive = false;
2208
- ws.ping();
2209
- });
2210
- }, HEARTBEAT_INTERVAL);
2211
- }
2212
-
2213
- /**
2214
- * Handle WebSocket message
2215
- * @param {WebSocket} ws - WebSocket client
2216
- * @param {Buffer} message - Message encoded with messagepack
2217
- * @private
2218
- */
2219
- async #handleMessage(ws, message) {
2220
- if (ws.isTerminating) return;
2221
- try {
2222
- const data = decode(message);
2223
-
2224
- // Apply rate limiting to all connections (including unregistered)
2225
- if (!this.#checkRateLimit(ws.connectionId, data.type)) {
2226
- if (!ws.isTerminating) {
2227
- ws.isTerminating = true; // Prevent multiple terminate calls (it is async)
2228
- ws.terminate();
2229
- console.error(`PlaySocket connection ${ws.connectionId} terminated due to rate limit violations.`);
2230
- return;
2231
- }
2232
- }
2233
-
2234
- switch (data.type) {
2235
- case 'register': {
2236
- // Register client ID if provided & check for a duplicate
2237
- if (data.id && (this.#clients.get(data.id) || data.id === "server")) {
2238
- ws.send(encode({ type: 'registration_failed', reason: 'ID is taken.' }), { binary: true });
2239
- return;
2240
- }
2241
-
2242
- // Generate client ID if none provided
2243
- if (!data.id) {
2244
- for (let i = 0; i < 50; i++) {
2245
- const id = this.#generateId();
2246
- if (!this.#clients.get(id)) {
2247
- data.id = id;
2248
- break;
2249
- }
2250
- }
2251
- if (!data.id) {
2252
- ws.send(encode({ type: 'registration_failed', reason: 'No available ID found.' }), { binary: true });
2253
- throw new Error('Failed to generate unique ID!');
2254
- }
2255
- }
2256
-
2257
- // Event callback
2258
- const registrationAllowed = await this.#triggerEvent("clientRegistrationRequested", data.id, data.customData);
2259
- if (registrationAllowed === false || typeof registrationAllowed === 'string') {
2260
- ws.send(encode({
2261
- type: 'registration_failed',
2262
- reason: typeof registrationAllowed === 'string' ? registrationAllowed : 'Denied.'
2263
- }), { binary: true });
2264
- return;
2265
- }
2266
-
2267
- ws.clientId = data.id; // Adds the provided id to the ws object as clientId
2268
- this.#clients.set(data.id, ws);
2269
- const sessionToken = this.#generateSessionToken();
2270
- this.#clientTokens.set(data.id, sessionToken); // Token is used when reconnecting to prevent impersonation attacks
2271
- ws.send(encode({ type: 'registered', id: data.id, sessionToken }), { binary: true });
2272
- this.#triggerEvent("clientRegistered", data.id, data.customData);
2273
- break;
2274
- }
2275
-
2276
- case 'reconnect': {
2277
- // If user is pending disconnect, respond (otherwise it's too late)
2278
- const pd = this.#pendingDisconnects.get(data.id);
2279
- if (pd && data.sessionToken) {
2280
- if (data.sessionToken !== this.#clientTokens.get(data.id)) {
2281
- ws.send(encode({
2282
- type: 'reconnection_failed',
2283
- reason: "Session token does not match."
2284
- }), { binary: true });
2285
- return;
2286
- }
2287
- clearTimeout(pd.timeout);
2288
- this.#pendingDisconnects.delete(data.id);
2289
-
2290
- // Re-assign old client ID to the new ws connection
2291
- ws.clientId = data.id;
2292
- this.#clients.set(data.id, ws);
2293
-
2294
- // If they were in a room, provide updated room data
2295
- let roomData;
2296
- const formerRoomId = this.#clientRooms.get(data.id);
2297
- const formerRoom = this.#rooms[formerRoomId];
2298
- if (formerRoom) {
2299
- // If the room has no host (they were the only one and disconnected), restore them as host
2300
- if (formerRoom.host === null) formerRoom.host = data.id;
2301
- if (this.#debug) console.log(`State sent for reconnection for room ${formerRoomId}:`, formerRoom.crdtManager.state);
2302
- roomData = {
2303
- state: formerRoom.crdtManager.state,
2304
- participantCount: formerRoom.participants.length,
2305
- host: formerRoom.host,
2306
- version: this.#roomVersions.get(formerRoomId)
2307
- };
2308
- }
2309
-
2310
- ws.send(encode({ type: 'reconnected', roomData }), { binary: true });
2311
- } else {
2312
- ws.send(encode({ type: 'reconnection_failed', reason: "Client unknown to server." }), { binary: true });
2313
- }
2314
- break;
2315
- }
2316
-
2317
- case 'create_room': {
2318
- if (!ws.clientId) return;
2319
-
2320
- if (this.#clientRooms.get(ws.clientId)) {
2321
- ws.send(encode({
2322
- type: 'room_creation_failed',
2323
- reason: 'Already in a room.'
2324
- }), { binary: true });
2325
- return;
2326
- }
2327
-
2328
- try {
2329
- // Event callback with potential initial storage modifications
2330
- const reviewedStorage = await this.#triggerEvent("roomCreationRequested", { clientId: ws.clientId, initialStorage: structuredClone({ ...data.initialStorage }) });
2331
- if (typeof reviewedStorage === 'object') data.initialStorage = reviewedStorage;
2332
- if (reviewedStorage === false) throw new Error("Room creation request denied.");
2333
-
2334
- const newRoom = this.createRoom(data.initialStorage, data.size, ws.clientId); // Create room
2335
-
2336
- // Verify client is still connected and abort if not
2337
- if (!this.#clients.has(ws.clientId)) {
2338
- this.destroyRoom(newRoom.id);
2339
- if (this.#debug) console.log(`Room creation cancelled - client ${ws.clientId} disconnected during event callback.`);
2340
- return;
2341
- }
2342
-
2343
- this.#rooms[newRoom.id].participants.push(ws.clientId); // Add client to the room
2344
- this.#clientRooms.set(ws.clientId, newRoom.id); // Add client to the client-room map
2345
- ws.send(encode({ type: 'room_created', state: newRoom.state, roomId: newRoom.id, participantCount: this.#rooms[newRoom.id].participants.length }), { binary: true });
2346
- } catch (error) {
2347
- console.error("PlaySocket error creating room:", error);
2348
- ws.send(encode({ type: 'room_creation_failed', reason: error.message }), { binary: true });
2349
- }
2350
- break;
2351
- }
2352
-
2353
- case 'join_room': {
2354
- if (!data.roomId || !ws.clientId) return;
2355
- const roomId = data.roomId;
2356
-
2357
- const rejectJoin = (reason) => {
2358
- ws.send(encode({ type: 'join_rejected', reason }), { binary: true });
2359
- };
2360
-
2361
- // Event callback
2362
- const joinAllowed = await this.#triggerEvent("clientJoinRequested", ws.clientId, roomId);
2363
- if (joinAllowed === false || typeof joinAllowed === 'string') return rejectJoin(typeof joinAllowed === 'string' ? joinAllowed : 'Denied.');
2364
-
2365
- // Verify client is still connected and abort if not
2366
- if (!this.#clients.has(ws.clientId)) {
2367
- if (this.#debug) console.log(`Room join cancelled - client ${ws.clientId} disconnected during event callback.`);
2368
- return;
2369
- }
2370
-
2371
- try {
2372
- this.#joinRoom(ws.clientId, roomId);
2373
- } catch (error) {
2374
- rejectJoin(error.message);
2375
- }
2376
-
2377
- break;
2378
- }
2379
-
2380
- case 'update_property': {
2381
- const roomId = this.#clientRooms.get(ws.clientId);
2382
- const room = roomId ? this.#rooms[roomId] : null;
2383
-
2384
- if (room && data.update) {
2385
- // Check if update is allowed via event callback (provide clone to ensure update integrity)
2386
- const updateAllowed = await this.#triggerEvent("storageUpdateRequested", { roomId, clientId: ws.clientId, update: structuredClone(data.update), storage: this.getRoomStorage(roomId) });
2387
- if (updateAllowed === false) {
2388
- ws.send(encode({
2389
- type: 'property_update_rejected',
2390
- state: room.crdtManager.state
2391
- }), { binary: true });
2392
- return;
2393
- }
2394
-
2395
- room.crdtManager.importPropertyUpdate(data.update); // Import update into server state
2396
-
2397
- // Increment version for this room
2398
- const currentVersion = this.#roomVersions.get(roomId) + 1;
2399
- this.#roomVersions.set(roomId, currentVersion);
2400
-
2401
- room.participants?.forEach(p => {
2402
- const client = this.#clients.get(p);
2403
- if (client) {
2404
- client.send(encode({
2405
- type: 'property_updated',
2406
- update: data.update,
2407
- version: currentVersion
2408
- }), { binary: true });
2409
- }
2410
- });
2411
-
2412
- this.#triggerEvent("storageUpdated", { roomId, clientId: ws.clientId, update: structuredClone(data.update), storage: this.getRoomStorage(roomId) });
2413
- if (this.#debug) console.log("Property update received and imported:", data.update);
2414
- }
2415
- break;
2416
- }
2417
-
2418
- case 'request': {
2419
- if (!ws.clientId) return;
2420
- const roomId = this.#clientRooms.get(ws.clientId) || null;
2421
- this.#triggerEvent("requestReceived", { roomId, clientId: ws.clientId, name: data.request.name, data: data.request.data });
2422
- break;
2423
- }
2424
-
2425
- case 'disconnect':
2426
- // Client signals to server that it will will willfully disconnect soon
2427
- ws.willfulDisconnect = true;
2428
- break;
2429
- }
2430
- } catch (error) {
2431
- console.error('PlaySocket error in message handler:', error);
2432
- }
2433
- }
2434
-
2435
- /**
2436
- * Generate a readable, 6 digit ID
2437
- * @returns {string} - Id
2438
- */
2439
- #generateId() {
2440
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789';
2441
- return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
2442
- }
2443
-
2444
- /**
2445
- * Migrate the host role to another participant
2446
- * @param {string} roomId - ID of the room
2447
- * @param {string} clientId - ID of the current host
2448
- */
2449
- #migrateHost(roomId, clientId) {
2450
- const room = this.#rooms[roomId];
2451
- if (!room) throw new Error("Room not found.");
2452
- if (room.host !== clientId) throw new Error("Client is not the host.");
2453
-
2454
- const remainingParticipants = room.participants.filter(p => p !== clientId);
2455
-
2456
- if (remainingParticipants.length >= 1) {
2457
- // Inform all participants about the new host
2458
- room.host = remainingParticipants[0];
2459
- remainingParticipants.forEach(p => {
2460
- const client = this.#clients.get(p);
2461
- if (client) client.send(encode({
2462
- type: 'host_migrated',
2463
- newHost: room.host,
2464
- }), { binary: true });
2465
- });
2466
- } else room.host = null; // The next person to join will become the host
2467
- }
2468
-
2469
- /**
2470
- * Generate a random token to prevent malicious reconnect attempts
2471
- * @returns {string} - Token
2472
- */
2473
- #generateSessionToken() {
2474
- let token = '';
2475
- for (let i = 0; i < 16; i++) token += Math.floor(Math.random() * 16).toString(16);
2476
- return token;
2477
- }
2478
-
2479
- /**
2480
- * Check rate limit using token bucket algorithm
2481
- * @param {string} connectionId - Random connection UUID which is different from the client ID
2482
- * @param {string} actionType - A string describing the action
2483
- * @returns {boolean} - Whether or not the action can be allowed, true means allowed, false means limited
2484
- * @private
2485
- */
2486
- #checkRateLimit(connectionId, actionType) {
2487
- const now = Date.now();
2488
-
2489
- if (!this.#rateLimits.has(connectionId)) {
2490
- this.#rateLimits.set(connectionId, { points: this.#rateLimitMaxPoints, lastReset: now });
2491
- return true;
2492
- }
2493
-
2494
- const limit = this.#rateLimits.get(connectionId);
2495
-
2496
- // Reset points if interval has passed (1s)
2497
- if (now - limit.lastReset > 1000) {
2498
- limit.points = this.#rateLimitMaxPoints;
2499
- limit.lastReset = now;
2500
- }
2501
-
2502
- const pointCost = actionType == "create_room" ? 5 : 1;
2503
- if (limit.points < pointCost) return false;
2504
-
2505
- limit.points -= pointCost;
2506
- return true;
2507
- }
2508
-
2509
- /**
2510
- * Handle client disconnection
2511
- * @param {WebSocket} ws - WebSocket client
2512
- * @private
2513
- */
2514
- #handleDisconnection(ws) {
2515
- this.#rateLimits.delete(ws.connectionId);
2516
-
2517
- if (ws.clientId) {
2518
- this.#clients.delete(ws.clientId); // Immediately remove from active clients (otherwise, server would try to message this client)
2519
-
2520
- // If client was in a room, check if they were the host & migrate
2521
- const roomId = this.#clientRooms.get(ws.clientId);
2522
- if (roomId != null && this.#rooms[roomId]?.host === ws.clientId) this.#migrateHost(roomId, ws.clientId);
2523
-
2524
- if (ws.willfulDisconnect) this.#disconnectClient(ws); // Immediate disconnection
2525
- else {
2526
- // Pending complete disconnection with 5s grace period to allow for reconnections
2527
- this.#pendingDisconnects.set(ws.clientId, {
2528
- timeout: setTimeout(() => {
2529
- this.#disconnectClient(ws);
2530
- }, RECONNECT_GRACE_PERIOD)
2531
- });
2532
- }
2533
- }
2534
- }
2535
-
2536
- /**
2537
- * Disconnect a client
2538
- * @private
2539
- * @param {WebSocket} ws - WebSocket client
2540
- */
2541
- #disconnectClient(ws) {
2542
- this.#pendingDisconnects.delete(ws.clientId);
2543
- this.#clientTokens.delete(ws.clientId);
2544
- const roomId = this.#clientRooms.get(ws.clientId);
2545
- if (roomId && this.#rooms[roomId]) this.#leaveRoom(ws.clientId, roomId);
2546
- this.#triggerEvent("clientDisconnected", ws.clientId);
2547
- }
2548
-
2549
- /**
2550
- * Register an event callback
2551
- * @param {string} event - Event name
2552
- * @param {Function} callback - Callback function
2553
- */
2554
- onEvent(event, callback) {
2555
- const validEvents = ["clientRegistered", "clientRegistrationRequested", "clientDisconnected", "clientJoinedRoom", "clientLeftRoom", "clientJoinRequested", "roomCreated", "roomCreationRequested", "requestReceived", "storageUpdated", "storageUpdateRequested", "roomDestroyed"];
2556
- if (!validEvents.includes(event)) {
2557
- console.warn(`Invalid PlaySocket event type "${event}"`);
2558
- return;
2559
- } if (!this.#callbacks.has(event)) this.#callbacks.set(event, []);
2560
- this.#callbacks.get(event).push(callback);
2561
- }
2562
-
2563
- /**
2564
- * Kick a client from the server
2565
- * @param {string} clientId - Client ID
2566
- * @param {string} [reason] - Optional reason
2567
- */
2568
- kick(clientId, reason) {
2569
- const client = this.#clients.get(clientId);
2570
- if (!client) return; // Client not connected, skip
2571
- client.willfulDisconnect = true;
2572
- client.send(encode({ type: 'kicked', reason }), { binary: true });
2573
- client.close();
2574
- }
2575
-
2576
- /**
2577
- * Move client into a different room
2578
- * @param {string} clientId - Client ID
2579
- * @param {string} roomId - Room ID (Target)
2580
- */
2581
- move(clientId, roomId) {
2582
- const client = this.#clients.get(clientId);
2583
- const oldRoomId = this.#clientRooms.get(clientId);
2584
- const oldRoom = this.#rooms[oldRoomId];
2585
- const targetRoom = this.#rooms[roomId];
2586
- if (!client && !this.#pendingDisconnects.has(clientId)) throw new Error("Client not found.");
2587
- if (!oldRoomId) throw new Error("Client is not in a room.");
2588
- if (!oldRoom) throw new Error("Client room not found.");
2589
- if (oldRoomId === roomId) throw new Error("Client is already in target room.");
2590
- if (!targetRoom) throw new Error("Target room not found.");
2591
- if (targetRoom.participants.length >= targetRoom.size) throw new Error("Target room is full.");
2592
- if (oldRoom.host === clientId) this.#migrateHost(oldRoomId, clientId);
2593
- this.#leaveRoom(clientId, oldRoomId);
2594
- this.#joinRoom(clientId, roomId);
2595
- }
2596
-
2597
- /**
2598
- * Trigger an event to registered callbacks
2599
- * @param {string} event - Event name
2600
- * @param {...*} args - Arguments
2601
- * @returns {any} - Undefined if no callbacks, true if all callbacks ran, what the first callback with a return statement returns if present
2602
- * @private
2603
- */
2604
- async #triggerEvent(event, ...args) {
2605
- const callbacks = this.#callbacks.get(event);
2606
- if (!callbacks) return;
2607
-
2608
- for (const callback of callbacks) {
2609
- try {
2610
- const result = await callback(...args);
2611
- if (result != null) return result; // Return any non-null/undefined result
2612
- } catch (error) {
2613
- console.error(`PlaySocket ${event} callback error:`, error);
2614
- }
2615
- }
2616
- return true;
2617
- }
2618
-
2619
- /**
2620
- * Get snapshot of a room's storage
2621
- * @param {string} roomId - ID of the room to get the storage from
2622
- * @returns {object|undefined} - Storage object or undefined if the room doesn't exist
2623
- */
2624
- getRoomStorage(roomId) {
2625
- const room = this.#rooms[roomId];
2626
- if (room) return room.crdtManager.propertyStore;
2627
- }
2628
-
2629
- /**
2630
- * Update a value in a room's storage
2631
- * @param {string} roomId - Room ID
2632
- * @param {string} key - Storage key
2633
- * @param {'set' | 'array-add' | 'array-add-unique' | 'array-remove-matching' | 'array-update-matching'} type - Operation type
2634
- * @param {*} value - New value or value to operate on
2635
- * @param {*} updateValue - New value for update-matching
2636
- */
2637
- updateRoomStorage(roomId, key, type, value, updateValue) {
2638
- if (this.#debug) console.log(`Playsocket server property update for room ${roomId}, key ${key}, operation ${type}, value ${value} and updateValue ${updateValue}.`);
2639
- const room = this.#rooms[roomId];
2640
- if (room) {
2641
- const propertyUpdate = room.crdtManager.updateProperty(key, type, value, updateValue);
2642
- const currentVersion = this.#roomVersions.get(roomId) + 1;
2643
- this.#roomVersions.set(roomId, currentVersion); // Increment version for this room
2644
-
2645
- room.participants?.forEach(p => {
2646
- const client = this.#clients.get(p);
2647
- if (client) {
2648
- client.send(encode({
2649
- type: 'property_updated',
2650
- update: propertyUpdate,
2651
- version: currentVersion
2652
- }), { binary: true });
2653
- }
2654
- });
2655
- this.#triggerEvent("storageUpdated", { roomId, clientId: null, update: structuredClone(propertyUpdate), storage: this.getRoomStorage(roomId) });
2656
- }
2657
- }
2658
-
2659
- /**
2660
- * Create a room
2661
- * @param {object} [initialStorage] - Optional initial storage object
2662
- * @param {number} [size] - Max. room size, up to 500
2663
- * @param {string} [host] - Host ID, defaults to "server" (when set to "server", room will not be deleted if all clients leave)
2664
- * @returns {object} Object containing room state and room ID
2665
- */
2666
- createRoom(initialStorage, size, host = "server") {
2667
- let newRoomId;
2668
-
2669
- for (let i = 0; i < 100; i++) {
2670
- const id = this.#generateId();
2671
- if (!this.#rooms[id]) {
2672
- newRoomId = id;
2673
- break;
2674
- }
2675
- }
2676
-
2677
- if (!newRoomId) throw new Error("No available ID found.");
2678
- const roomCrdtManager = new CRDTManager(this.#debug);
2679
-
2680
- if (initialStorage) Object.entries(initialStorage)?.forEach(([key, value]) => {
2681
- roomCrdtManager.updateProperty(key, "set", value);
2682
- });
2683
-
2684
- this.#roomVersions.set(newRoomId, 0);
2685
- this.#rooms[newRoomId] = {
2686
- participants: [],
2687
- host,
2688
- size: Math.min(Number(size), MAX_ROOM_SIZE) || MAX_ROOM_SIZE,
2689
- crdtManager: roomCrdtManager
2690
- };
2691
-
2692
- this.#triggerEvent("roomCreated", newRoomId);
2693
- if (this.#debug) console.log(`Room ${newRoomId} created with initial storage:`, initialStorage);
2694
- return { state: roomCrdtManager.state, id: newRoomId };
2695
- }
2696
-
2697
- /**
2698
- * Make a client join a room
2699
- * @param {string} clientId - Client ID
2700
- * @param {string} roomId - Room ID
2701
- */
2702
- #joinRoom(clientId, roomId) {
2703
- const room = this.#rooms[roomId];
2704
- const client = this.#clients.get(clientId);
2705
- if (!room) throw new Error("Room not found.");
2706
- if (!client && !this.#pendingDisconnects.has(clientId)) throw new Error("Client not found.");
2707
- if (this.#clientRooms.get(clientId)) throw new Error("Already in a room.");
2708
- if (room.participants.length >= room.size) throw new Error("Room full.");
2709
-
2710
- room.participants.push(clientId);
2711
- this.#clientRooms.set(clientId, roomId);
2712
-
2713
- // Notify joiner with initial storage (only if connected - pending disconnect clients get state on reconnect)
2714
- if (client) {
2715
- if (this.#debug) console.log("Room state sent for join:", room.crdtManager.state);
2716
- client.send(encode({
2717
- type: 'join_accepted',
2718
- roomId,
2719
- state: room.crdtManager.state,
2720
- participantCount: room.participants.length,
2721
- host: room.host,
2722
- version: this.#roomVersions.get(roomId)
2723
- }), { binary: true });
2724
- }
2725
-
2726
- // If the room has no host (previous host disconnected and was the only participant), make joiner the host
2727
- // Set new host after join_accepted so hostMigrated event fires correctly on client
2728
- const becameHost = room.host === null;
2729
- if (becameHost) {
2730
- room.host = clientId; // Change host
2731
- if (client) {
2732
- client.send(encode({
2733
- type: 'host_migrated',
2734
- newHost: room.host,
2735
- }), { binary: true });
2736
- }
2737
- }
2738
-
2739
- // Notify existing participants
2740
- room.participants.forEach(p => {
2741
- if (p === clientId) return;
2742
- const client = this.#clients.get(p);
2743
- if (client) {
2744
- client.send(encode({
2745
- type: 'client_joined',
2746
- client: clientId,
2747
- participantCount: room.participants.length
2748
- }), { binary: true });
2749
- }
2750
- });
2751
- this.#triggerEvent("clientJoinedRoom", clientId, roomId);
2752
- }
2753
-
2754
- #leaveRoom(clientId, roomId) {
2755
- const room = this.#rooms[roomId];
2756
- if (!room) throw new Error("Room not found.");
2757
-
2758
- room.participants = room.participants.filter(p => p !== clientId); // Remove client from room
2759
- this.#clientRooms.delete(clientId);
2760
- this.#triggerEvent("clientLeftRoom", clientId, roomId); // Before potential room destruction to allow for accessing its data in callback
2761
-
2762
- if (room.participants.length === 0 && room.host !== "server") {
2763
- this.destroyRoom(roomId); // Destroy room if now empty & not by server
2764
- } else {
2765
- // Notify remaining participants
2766
- room.participants.forEach(p => {
2767
- const client = this.#clients.get(p);
2768
- if (client) client.send(encode({
2769
- type: 'client_left',
2770
- client: clientId,
2771
- participantCount: room.participants.length
2772
- }), { binary: true });
2773
- });
2774
- }
2775
- }
2776
-
2777
- /**
2778
- * Destroy a room
2779
- * @param {string} roomId - Room ID
2780
- */
2781
- destroyRoom(roomId) {
2782
- const room = this.#rooms[roomId];
2783
- if (!room) throw new Error("Room not found.");
2784
-
2785
- // Disconnect clients if still in room
2786
- const participants = room.participants;
2787
- participants.forEach((clientId) => {
2788
- this.kick(clientId, "Room destroyed by server.");
2789
- });
2790
-
2791
- // Delete the room
2792
- delete this.#rooms[roomId];
2793
- this.#roomVersions.delete(roomId); // Delete room version (used to ensure all clients are up-2-date)
2794
- this.#triggerEvent("roomDestroyed", roomId);
2795
- if (this.#debug) console.log("Deleted room with id " + roomId + ".");
2796
- }
2797
-
2798
- /**
2799
- * Close all client connections, then close the websocket and http server
2800
- */
2801
- stop() {
2802
- clearInterval(this.#heartbeatInterval);
2803
- if (this.#wss) {
2804
- this.#clients.forEach((client, clientId) => this.kick(clientId, "Server restart."));
2805
- this.#wss.close();
2806
- }
2807
- if (this.#server && this.#ownsServer) this.#server.close(() => { console.log('PlaySocket server stopped.'); });
2808
- this.#pendingDisconnects.forEach((data) => {
2809
- clearTimeout(data.timeout);
2810
- });
2811
- }
2812
-
2813
- get rooms() { return structuredClone(this.#rooms) }
2814
- }
2815
-
2816
- export { RECONNECT_GRACE_PERIOD, PlaySocketServer as default };