topazcube 0.0.3 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts ADDED
@@ -0,0 +1,1111 @@
1
+ import * as https from 'node:https'
2
+ import * as fs from 'node:fs'
3
+ import {
4
+ reactive,
5
+ deepGet,
6
+ clonewo_,
7
+ opmsg,
8
+ msgop,
9
+ encode_uint32,
10
+ encode_fp412,
11
+ encode_fp168,
12
+ encode_fp1616,
13
+ limitPrecision,
14
+ encode,
15
+ decode
16
+ } from './utils'
17
+ import { compress, decompress } from './compress-node'
18
+ import fastjsonpatch from 'fast-json-patch'
19
+ import { WebSocketServer } from 'ws'
20
+ import { MongoClient } from 'mongodb'
21
+ import * as wrtc from '@roamhq/wrtc' // Server-side WebRTC implementation
22
+ import { doesNotThrow } from 'assert'
23
+ import { glMatrix, vec3, quat } from 'gl-matrix'
24
+ glMatrix.setMatrixArrayType(Array)
25
+
26
+ // entities/ID/
27
+ const fastPatchProperties:any = {
28
+ 'type': true, // string 'enemy'
29
+ 'status': true, // string 'idle'
30
+ 'level': true, // number 2
31
+ 'race': true, // string 'goblin'
32
+ 'class': true, // string 'warrior'
33
+ 'model': true, // string 'models/models.glb|goblin'
34
+ 'animation': true, // string 'idle2'
35
+ 'sound': true, // string 'sound/goblin.snd|snarl'
36
+ 'effect': true, // 'selected'
37
+ 'position': true, // [0, 0, 0] Vector (Number)
38
+ 'rotation': true, // [0, 0, 0, 1] Quaternion (Number)
39
+ 'scale': true, // [1, 1, 1] Vector (Number)
40
+ }
41
+
42
+ const dictionaryProperties:any = {
43
+ 'type': true,
44
+ 'status': true,
45
+ 'level': true,
46
+ 'race': true,
47
+ 'class': true,
48
+ 'model': true,
49
+ 'animation': true,
50
+ 'sound': true,
51
+ 'effect': true,
52
+ }
53
+
54
+ const { applyOperation } = fastjsonpatch
55
+ const LITTLE_ENDIAN = (() => {
56
+ const buffer = new ArrayBuffer(2)
57
+ new DataView(buffer).setInt16(0, 256, true)
58
+ return new Int16Array(buffer)[0] === 256
59
+ })()
60
+
61
+ const MAX_PACKAGE_SIZE = 65400; // Slightly below the 65535 limit to allow for overhead
62
+
63
+ export default class TopazCubeServer {
64
+ name = 'TopazCubeServer'
65
+ cycle = 100
66
+ patchCycleDivider = 1
67
+ port = 8799
68
+ useHttps = false
69
+ key = './cert/key.pem'
70
+ cert = './cert/fullchain.pem'
71
+ MongoUrl = 'mongodb://localhost:27017'
72
+ mongoClient:any = null
73
+ DB:any = null
74
+ database = 'topazcube'
75
+ collection = 'documents'
76
+ allowSave = true
77
+ allowSync = true
78
+ allowWebRTC = false
79
+ allowFastPatch = false
80
+ allowCompression = false
81
+ simulateLatency = 0
82
+
83
+ _lastUID = 100
84
+ clients = []
85
+ documents = {}
86
+ isLoading = {}
87
+ _documentChanges = {}
88
+ _documentState = {}
89
+
90
+ update = 0
91
+ lastUpdate = 0
92
+ _loopiv:any = null
93
+ _statsiv:any = null
94
+ _stillUpdating = false
95
+ stats = {
96
+ tUpdate: [],
97
+ tPatch: [],
98
+ send: 0,
99
+ sendRTC: 0,
100
+ _sendRTCUpdate: 0
101
+ }
102
+
103
+ _wss:any = null
104
+ _exited = false
105
+
106
+ constructor({
107
+ name = 'TopazCubeServer',
108
+ cycle = 100,
109
+ port = 8799,
110
+ useHttps = false,
111
+ key = './cert/key.pem',
112
+ cert = './cert/fullchain.pem',
113
+ MongoUrl = 'mongodb://localhost:27017',
114
+ database = 'topazcube',
115
+ collection = 'documents',
116
+ allowSave = true,
117
+ allowSync = true,
118
+ allowWebRTC = false,
119
+ allowFastPatch = false,
120
+ allowCompression = false,
121
+ simulateLatency = 0,
122
+ }) {
123
+ this.name = name
124
+ this.cycle = cycle
125
+ this.port = port
126
+ this.useHttps = useHttps
127
+ this.key = key
128
+ this.cert = cert
129
+ this.MongoUrl = MongoUrl
130
+ this.database = database
131
+ this.collection = collection
132
+ this.allowSave = allowSave
133
+ this.allowSync = allowSync
134
+ this.allowWebRTC = allowWebRTC
135
+ this.allowFastPatch = allowFastPatch
136
+ this.allowCompression = allowCompression
137
+ this.simulateLatency = simulateLatency
138
+
139
+ this._initDB()
140
+
141
+ if (useHttps) {
142
+ let httpsServer:any = https.createServer({
143
+ key: fs.readFileSync(this.key),
144
+ cert: fs.readFileSync(this.cert),
145
+ }, (req, res) => {
146
+ res.writeHead(200)
147
+ res.end('<b>Hello World!</b>')
148
+ }).listen(this.port)
149
+ this._wss = new WebSocketServer({ server: httpsServer })
150
+ httpsServer = null
151
+ console.log(this.name + ' running on HTTPS port ' + this.port)
152
+ } else {
153
+ this._wss = new WebSocketServer({ port: this.port })
154
+ console.log(this.name + ' running on port ' + this.port)
155
+ }
156
+ this._wss.on('connection', (client) => {
157
+ this._onConnected(client)
158
+ })
159
+
160
+ process.stdin.resume()
161
+ process.on('SIGINT', () => {
162
+ this._exitSignal('SIGINT')
163
+ })
164
+ process.on('SIGQUIT', () => {
165
+ this._exitSignal('SIGQUIT')
166
+ })
167
+ process.on('SIGTERM', () => {
168
+ this._exitSignal('SIGTERM')
169
+ })
170
+ process.on('SIGUSR2', () => {
171
+ this._exitSignal('SIGUSR2')
172
+ })
173
+
174
+ // Setup keypress handling for console input
175
+ process.stdin.resume()
176
+ process.stdin.setEncoding('utf8')
177
+
178
+ process.stdin.on('data', (key:any) => {
179
+ key = (''+key).trim()
180
+
181
+ // ctrl-c ( end of text )
182
+ if (key == '\u0003') {
183
+ this._exitSignal('SIGINT')
184
+ return
185
+ }
186
+
187
+ // Process other keypresses
188
+ console.log(`Key pressed: ${key}`)
189
+
190
+ // Example: 's' to save all documents
191
+ if (key == 's') {
192
+ console.log('Saving all documents...')
193
+ this._saveAllDocuments()
194
+ }
195
+
196
+ // Example: 'i' to print server info
197
+ if (key == 'i') {
198
+ console.log(
199
+ `Server: ${this.name}, Clients: ${this.clients.length}, Documents: ${Object.keys(this.documents).length}`
200
+ )
201
+ }
202
+ })
203
+ this._startLoop()
204
+ }
205
+
206
+ /*= DOCUMENTS ==============================================================*/
207
+
208
+ // to be redefined. Called before a new document is created. Returns true if
209
+ // the client has the right to create an empty document
210
+ canCreate(client, name) {
211
+ return true
212
+ }
213
+
214
+ // to be redefined. Called when a new document is created
215
+ // (returns an empty document)
216
+ onCreate(name:string):any {
217
+ return {
218
+ data: {},
219
+ }
220
+ }
221
+
222
+ // to be redefined. Called when a client wants to sync (modify) a document.
223
+ // Returns true if the client has the right to sync that operation.
224
+ canSync(client, name, op) {
225
+ return true
226
+ }
227
+
228
+ // to be redefined. Called when a new document is hydrated
229
+ // (created, or loaded from db)
230
+ async onHydrate(name:string, document:any) {
231
+ document.__hydrated = true
232
+ }
233
+
234
+ _makeReactive(name) {
235
+ //console.log(`Making document '${name}' reactive`, this.documents[name])
236
+ let ep = false
237
+ if (this.allowFastPatch) {
238
+ ep = fastPatchProperties
239
+ }
240
+ this.documents[name] = reactive(
241
+ name,
242
+ this.documents[name],
243
+ this._onDocumentChange.bind(this),
244
+ '',
245
+ ep
246
+ )
247
+ if (!this._documentChanges[name]) {
248
+ this._documentChanges[name] = []
249
+ }
250
+ }
251
+
252
+ _createEmptyDocument(name) {
253
+ let doc = this.onCreate(name)
254
+ if (!doc) {
255
+ return
256
+ }
257
+ this.documents[name] = doc
258
+ }
259
+
260
+ async _waitLoad(name) {
261
+ if (this.isLoading[name]) {
262
+ while (this.isLoading[name]) {
263
+ await new Promise((resolve) => setTimeout(resolve, 50))
264
+ }
265
+ }
266
+ }
267
+
268
+ async _checkDocument(name, client) {
269
+ await this._waitLoad(name)
270
+ if (!this.documents[name]) {
271
+ this.isLoading[name] = true
272
+ await this._loadDocument(name)
273
+ if (!this.documents[name] && this.canCreate(client, name)) {
274
+ this._createEmptyDocument(name)
275
+ }
276
+ if (this.documents[name]) {
277
+ this._makeReactive(name)
278
+ this.onHydrate(name, this.documents[name])
279
+ }
280
+ this.isLoading[name] = false
281
+ this._documentState[name] = {
282
+ subscibers: 0,
283
+ lastModified: Date.now(),
284
+ }
285
+ }
286
+ }
287
+
288
+ _updateAllDocumentsState() {
289
+ for (let name in this.documents) {
290
+ if (name != '_server') {
291
+ let doc = this.documents[name]
292
+ this._documentState[name].subscibers = 0
293
+ for (let client of this.clients) {
294
+ if (client.subscribed[name]) {
295
+ this._documentState[name].subscibers++
296
+ }
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ /*= UPDATE LOOP ============================================================*/
303
+
304
+ // to be redefined. called every this.cycle ms
305
+ onUpdate(name, doc, dt) {}
306
+
307
+ _startLoop() {
308
+ this.lastUpdate = Date.now()
309
+ this._loop()
310
+ this._statsiv = setInterval(() => {
311
+ this._doStats()
312
+ }, 1000)
313
+ }
314
+
315
+ _loop() {
316
+ let now = Date.now()
317
+ let dtms = (now - this.lastUpdate)
318
+ let dt = dtms / 1000.0 // Convert to seconds
319
+ this.lastUpdate = now
320
+
321
+ /*
322
+ if (this._stillUpdating) {
323
+ return
324
+ }
325
+ */
326
+ this._stillUpdating = true
327
+ for (let name in this.documents) {
328
+ this.onUpdate(name, this.documents[name], dt)
329
+ }
330
+ let t1 = Date.now()
331
+ this._stillUpdating = false
332
+ let updateTime = t1 - now
333
+ this.stats.tUpdate.push(updateTime)
334
+
335
+ //console.log(`update ${this.update} patch: ${this.update % this.patchCycleDivider}`, )
336
+
337
+ let patchTime = 0
338
+ if (this.update % this.patchCycleDivider == 0) {
339
+ this._sendPatches()
340
+ let t2 = Date.now()
341
+ patchTime = t2 - t1
342
+ this.stats.tPatch.push(patchTime)
343
+ if (this.allowFastPatch) {
344
+ console.log(`update ${this.update} dt:${dtms}ms RTC:${this.stats._sendRTCUpdate}bytes, tUpdate: ${updateTime}ms, tPatch: ${patchTime}ms`, )
345
+ }
346
+ this.stats._sendRTCUpdate = 0
347
+ }
348
+
349
+ this.update++
350
+ let endUpdate = Date.now()
351
+ let totalUpdate = endUpdate - now
352
+
353
+ setTimeout(() => {
354
+ this._loop()
355
+ }, Math.max(this.cycle - totalUpdate, 10))
356
+ }
357
+
358
+ _doStats() {
359
+ for (let key in this.stats) {
360
+ let i = this.stats[key]
361
+ if (i.length > 0) {
362
+ while (i.length > 60) {
363
+ i.shift()
364
+ }
365
+ this.stats['_avg_' + key] = i.reduce((a, b) => a + b, 0) / i.length
366
+ } else if (!key.startsWith('_')) {
367
+ this.stats['_persec_' + key] = i / 3.0
368
+ this.stats[key] = 0
369
+ }
370
+ }
371
+ //console.log('stats', this.stats)
372
+ }
373
+
374
+ /*= MESSAGES ===============================================================*/
375
+
376
+ // to be redefined. Called on message (operation) from client
377
+ onMessage(client, message) {}
378
+
379
+ // to be redefined. Called when a client connects
380
+ onConnect(client) {}
381
+
382
+ // to be redefined. Called when a client disconnects
383
+ onDisconnect(client:any) {}
384
+
385
+ _onConnected(client:any) {
386
+ client.ID = this.getUID()
387
+ client.ping = 0
388
+ client.ctdiff = 0
389
+ client.subscribed = {}
390
+ client.dataChannel = null
391
+ client.peerConnection = null
392
+
393
+ console.log('client connected', client.ID)
394
+ this.clients.push(client)
395
+ client.on('error', () => {
396
+ this._onError(client, arguments)
397
+ })
398
+ client.on('message', (message) => {
399
+ let dec = decode(message)
400
+ if (this.simulateLatency) {
401
+ setTimeout(() => {
402
+ this._onMessage(client, dec)
403
+ }, this.simulateLatency)
404
+ } else {
405
+ this._onMessage(client, dec)
406
+ }
407
+ })
408
+ client.on('close', (message) => {
409
+ this._onDisconnected(client)
410
+ this.onDisconnect(client)
411
+ })
412
+ this.onConnect(client)
413
+ }
414
+
415
+ async _onMessage(client, message) {
416
+ if (
417
+ message.c == 'sync' &&
418
+ this.allowSync &&
419
+ client.subscribed[message.n] &&
420
+ this.documents[message.n]
421
+ ) {
422
+ let name = message.n
423
+ if (!this._documentChanges[name]) {
424
+ this._documentChanges[name] = []
425
+ }
426
+ for (let op of message.p) {
427
+ if (!this.canSync(client, name, op)) {
428
+ continue
429
+ }
430
+ this._documentChanges[name].push(op)
431
+ let dop = msgop(op)
432
+ applyOperation(this.documents[name], dop)
433
+ }
434
+ } else if (message.c == 'ping') {
435
+ this.send(client, {
436
+ c: 'pong',
437
+ t: Date.now(),
438
+ ct: message.ct,
439
+ ID: client.ID,
440
+ })
441
+ } else if (message.c == 'peng') {
442
+ let time = Date.now()
443
+ let ping = time - message.st
444
+ client.ctdiff = message.ct + ping / 2 - time
445
+ client.ping = ping
446
+ //console.log(time, "PENG ping, ctdiff", message, ping, client.ctdiff, "ms")
447
+ } else if (message.c == 'rtc-offer') {
448
+ this._processOffer(client, message)
449
+ } else if (message.c == 'rtc-candidate') {
450
+ this._processICECandidate(client, message)
451
+ } else if (message.c == 'sub') {
452
+ await this._checkDocument(message.n, client)
453
+ if (!this.documents[message.n]) {
454
+ this.send(client, {
455
+ c: 'error',
456
+ t: Date.now(),
457
+ message: 'Document not found',
458
+ })
459
+ return
460
+ }
461
+ client.subscribed[message.n] = true
462
+ this._sendFullState(message.n, client)
463
+ } else if (message.c == 'unsub') {
464
+ client.subscribed[message.n] = false
465
+ } else {
466
+ this.onMessage(client, message)
467
+ }
468
+ }
469
+
470
+ _onError(client, args) {
471
+ console.error('onError:', args)
472
+ }
473
+
474
+ _onDisconnected(client) {
475
+ if (client.dataChannel) {
476
+ client.dataChannel.close()
477
+ }
478
+ if (client.peerConnection) {
479
+ client.peerConnection.close()
480
+ }
481
+ console.log('client disconnected')
482
+ let index = this.clients.indexOf(client)
483
+ if (index !== -1) {
484
+ this.clients.splice(index, 1)
485
+ }
486
+ }
487
+
488
+ async send(client, message) {
489
+ try {
490
+ let t1 = Date.now()
491
+ let data = encode(message)
492
+ let t2 = Date.now()
493
+ let dl = data.byteLength
494
+ if (this.allowCompression) {
495
+ data = await compress(data)
496
+ }
497
+ let t3 = Date.now()
498
+ if (data.length > 4096) {
499
+ console.log(`Big message ${dl} -> ${data.length} (${(100.0 * data.length / dl).toFixed()}%) encoding:${t2 - t1}ms compression:${t3 - t1}ms`)
500
+ }
501
+ this.stats.send += data.byteLength
502
+ if (this.simulateLatency) {
503
+ setTimeout(() => {
504
+ client.send(data)
505
+ }, this.simulateLatency)
506
+ } else {
507
+ client.send(data)
508
+ }
509
+ } catch (e) {
510
+ console.error('Error sending message:', e, message)
511
+ }
512
+ }
513
+
514
+ async broadcast(message:object, clients:any = false) {
515
+ if (!clients) {
516
+ clients = this.clients
517
+ }
518
+ let data = encode(message)
519
+ if (this.allowCompression) {
520
+ data = await compress(data)
521
+ }
522
+ for (let client of this.clients) {
523
+ this.stats.send += data.byteLength
524
+ if (this.simulateLatency) {
525
+ setTimeout(() => {
526
+ client.send(data)
527
+ }, this.simulateLatency)
528
+ } else {
529
+ client.send(data)
530
+ }
531
+ }
532
+ }
533
+
534
+ async _sendFullState(name, client) {
535
+ await this._waitLoad(name)
536
+ let excluded:any = '_'
537
+ if (this.allowFastPatch) {
538
+ excluded = fastPatchProperties
539
+ }
540
+ let doc = clonewo_(this.documents[name], excluded)
541
+ limitPrecision(doc)
542
+ let fdata:any = false
543
+ if (this.allowFastPatch) {
544
+ fdata = this._encodeFastChanges(name, false)
545
+ }
546
+ let fullState = {
547
+ c: 'full',
548
+ le: LITTLE_ENDIAN,
549
+ t: Date.now(),
550
+ n: name,
551
+ doc: doc,
552
+ fdata: fdata
553
+ }
554
+ this.send(client, fullState)
555
+ }
556
+
557
+ _encodeFastChanges(name, changesOnly = true) {
558
+ let doc = this.documents[name]
559
+ if (!doc) { return false }
560
+ let origin = this.documents[name].origin
561
+ if (!origin) {
562
+ origin = [0, 0, 0]
563
+ this.documents[name].origin = origin
564
+ }
565
+
566
+ let entities = doc.entities
567
+ let ids = Object.keys(entities)
568
+ if (!entities) { return false }
569
+ let count = {}
570
+ let changed = {}
571
+ let hasChanges = {}
572
+ let dictionary = {}
573
+ let encodedChanges = {}
574
+
575
+ for (let key in fastPatchProperties) {
576
+ if (changesOnly) {
577
+ count[key] = 0
578
+ changed[key] = {}
579
+ hasChanges[key] = false
580
+ } else {
581
+ count[key] = ids.length
582
+ changed[key] = {}
583
+ hasChanges[key] = true
584
+ }
585
+ dictionary[key] = {}
586
+ }
587
+
588
+ // search for changes
589
+
590
+ if (changesOnly) {
591
+ for (let id in entities) {
592
+ let e = entities[id]
593
+ for (let key in fastPatchProperties) {
594
+ if (e['__changed_' + key]) {
595
+ changed[key][id] = true
596
+ count[key]++
597
+ hasChanges[key] = true
598
+ e['__changed_' + key] = false
599
+ }
600
+ }
601
+ }
602
+ } else {
603
+ for (let id in entities) {
604
+ for (let key in fastPatchProperties) {
605
+ changed[key][id] = true
606
+ }
607
+ }
608
+ }
609
+
610
+ // create dictionaries
611
+
612
+ let dictUID = 1
613
+ for (let key in hasChanges) {
614
+ if (hasChanges[key] && dictionaryProperties[key]) {
615
+ for (let id in changed[key]) {
616
+ let e = entities[id]
617
+ let value = e[key]
618
+ if (!dictionary[key][value]) {
619
+ dictionary[key][value] = dictUID++
620
+ }
621
+ }
622
+ }
623
+ }
624
+
625
+ console.log("--------------------------------------------------")
626
+ //console.log("changed", changed)
627
+ //console.log("count", count)
628
+
629
+ // create encoded changes
630
+ //
631
+ for (let key in hasChanges) {
632
+ if (hasChanges[key]) {
633
+ let size = count[key]
634
+ let encoded:any = {}
635
+ if (dictionaryProperties[key]) {
636
+ encoded.dict = dictionary[key]
637
+
638
+ let pdata = new Uint8Array(size * 8)
639
+ let offset = 0
640
+ for (let id in changed[key]) {
641
+ let e = entities[id]
642
+ let nid = parseInt(id)
643
+ encode_uint32(nid, pdata, offset)
644
+ offset += 4
645
+ let value = e[key]
646
+ let did = parseInt(dictionary[key][value])
647
+ encode_uint32(did, pdata, offset)
648
+ offset += 4
649
+ }
650
+ encoded.pdata = pdata
651
+ } else {
652
+
653
+ let pdata
654
+ if (key == 'position') {
655
+ pdata = new Uint8Array(size * 13)
656
+ } else if (key == 'rotation') {
657
+ pdata = new Uint8Array(size * 16)
658
+ } else if (key == 'scale') {
659
+ pdata = new Uint8Array(size * 16)
660
+ }
661
+
662
+ let offset = 0
663
+ for (let id in changed[key]) {
664
+ let e = entities[id]
665
+ let nid = parseInt(id)
666
+ encode_uint32(nid, pdata, offset)
667
+ offset += 4
668
+ if (key == 'position') {
669
+ encode_fp168(e.position[0] - origin[0], pdata, offset)
670
+ offset += 3
671
+ encode_fp168(e.position[1] - origin[1], pdata, offset)
672
+ offset += 3
673
+ encode_fp168(e.position[2] - origin[2], pdata, offset)
674
+ offset += 3
675
+ } else if (key == 'rotation') {
676
+ encode_fp412(e.rotation[0], pdata, offset)
677
+ offset += 2
678
+ encode_fp412(e.rotation[1], pdata, offset)
679
+ offset += 2
680
+ encode_fp412(e.rotation[2], pdata, offset)
681
+ offset += 2
682
+ encode_fp412(e.rotation[3], pdata, offset)
683
+ offset += 2
684
+ } else if (key == 'scale') {
685
+ encode_fp1616(e.scale[0], pdata, offset)
686
+ offset += 4
687
+ encode_fp1616(e.scale[1], pdata, offset)
688
+ offset += 4
689
+ encode_fp1616(e.scale[2], pdata, offset)
690
+ offset += 4
691
+ }
692
+ }
693
+ encoded.pdata = pdata
694
+ }
695
+ encodedChanges[key] = encoded
696
+ }
697
+ }
698
+
699
+ return encodedChanges
700
+ }
701
+
702
+ _sendPatches() {
703
+ let now = Date.now()
704
+
705
+ for (let name in this._documentChanges) {
706
+ let dc = this._documentChanges[name]
707
+ this._documentChanges[name] = []
708
+ let sus = this.clients.filter((client) => client.subscribed[name])
709
+ if (sus.length > 0) {
710
+ if (dc.length > 0) {
711
+ let record = {
712
+ c: 'patch',
713
+ t: now, // server time
714
+ u: this.update,
715
+ n: name,
716
+ doc: dc,
717
+ }
718
+ this.broadcast(record, sus)
719
+ }
720
+ }
721
+
722
+ if (this.allowFastPatch) {
723
+ if (sus.length > 0) {
724
+ let t1 = Date.now()
725
+ let changes = this._encodeFastChanges(name)
726
+ let t2 = Date.now()
727
+ let record = {
728
+ c: 'fpatch',
729
+ t: now, // server time
730
+ u: this.update,
731
+ n: name,
732
+ fdata: changes
733
+ }
734
+ this.broadcastRTC(record, sus)
735
+ let t3 = Date.now()
736
+ console.log(`_sendPatches: ${name} encode_changes: ${t2-t1}ms broadcast:${t3-t2}ms`)
737
+ }
738
+ }
739
+ }
740
+ }
741
+
742
+ _onDocumentChange(name, op, target, path, value) {
743
+ this._documentChanges[name].push(opmsg(op, target, path, value))
744
+ }
745
+
746
+ propertyChange(name, id, property) {
747
+ let doc = this.documents[name]
748
+ if (!doc) { return }
749
+ let entities = doc.entities
750
+ if (!entities) { return }
751
+ let e = entities[id]
752
+ if (!e) { return }
753
+ e['__changed_'+property] = true
754
+ //console.log('propertyChange', e)
755
+ }
756
+
757
+
758
+ /*= WEBRTC ===================================================================*/
759
+
760
+ async _processOffer(client, data) {
761
+ //console.log("RTC: Offer received", data);
762
+ const peerConnection = new wrtc.RTCPeerConnection({
763
+ iceServers: [
764
+ { urls: 'stun:stun.l.google.com:19302' },
765
+ { urls: 'stun:stun.cloudflare.com:3478' },
766
+ { urls: 'stun:freestun.net:3478' },
767
+ ],
768
+ iceCandidatePoolSize: 10,
769
+ })
770
+
771
+ client.peerConnection = peerConnection
772
+
773
+ peerConnection.onicecandidate = (event) => {
774
+ if (event.candidate) {
775
+ //console.log("RTC: ICE candidate generated", event.candidate.candidate.substring(0, 50) + "...");
776
+ this.send(client, {
777
+ c: 'rtc-candidate',
778
+ type: 'ice-candidate',
779
+ candidate: event.candidate.toJSON(),
780
+ })
781
+ } else {
782
+ //console.log("RTC: ICE candidate gathering complete");
783
+ }
784
+ }
785
+
786
+ peerConnection.onconnectionstatechange = () => {
787
+ //console.log(`RTC: Connection state changed: ${peerConnection.connectionState}`);
788
+ if (peerConnection.connectionState === 'connected') {
789
+ client.webRTCConnected = true
790
+ console.log(`RTC: Connection established with client ${client.ID}`)
791
+ } else if (
792
+ peerConnection.connectionState === 'failed' ||
793
+ peerConnection.connectionState === 'disconnected' ||
794
+ peerConnection.connectionState === 'closed'
795
+ ) {
796
+ client.webRTCConnected = false
797
+ console.log(`RTC: Connection failed or closed with client ${client.ID}`)
798
+ }
799
+ }
800
+
801
+ peerConnection.onicegatheringstatechange = () => {
802
+ //console.log(`RTC: ICE gathering state: ${peerConnection.iceGatheringState}`);
803
+ }
804
+
805
+ peerConnection.oniceconnectionstatechange = () => {
806
+ //console.log(`RTC: ICE connection state: ${peerConnection.iceConnectionState}`);
807
+ if (
808
+ peerConnection.iceConnectionState === 'connected' ||
809
+ peerConnection.iceConnectionState === 'completed'
810
+ ) {
811
+ //console.log(`RTC: ICE connection established with client ${client.ID}`);
812
+ }
813
+ }
814
+
815
+ try {
816
+ await peerConnection.setRemoteDescription(
817
+ new wrtc.RTCSessionDescription(data)
818
+ )
819
+ //console.log("RTC: Remote description set successfully");
820
+
821
+ client.dataChannel = peerConnection.createDataChannel('serverchannel', {
822
+ ordered: true,
823
+ maxRetransmits: 1,
824
+ })
825
+
826
+ client.dataChannel.onopen = () => {
827
+ //console.log(`RTC: Data channel opened for client ${client.ID}`);
828
+ // Try sending a test message
829
+ try {
830
+ const testData = { c: 'test', message: 'Hello WebRTC' }
831
+ this.sendRTC(client, testData)
832
+ } catch (e) {
833
+ console.error(
834
+ `RTC: Error sending test message to client ${client.ID}`,
835
+ e
836
+ )
837
+ }
838
+ }
839
+
840
+ client.dataChannel.onclose = () => {
841
+ console.log(`RTC: Data channel closed for client ${client.ID}`)
842
+ }
843
+
844
+ client.dataChannel.onerror = (error) => {
845
+ console.error(`RTC: Data channel error for client ${client.ID}:`, error)
846
+ }
847
+
848
+ client.dataChannel.onmessage = (event) => {
849
+ try {
850
+ const data = decode(event.data)
851
+ console.log(
852
+ `RTC: Data channel message from client ${client.ID}:`,
853
+ data
854
+ )
855
+ //this.onMessage(client, data);
856
+ } catch (error) {
857
+ console.error(
858
+ `RTC: Error decoding message from client ${client.ID}:`,
859
+ error
860
+ )
861
+ }
862
+ }
863
+
864
+ // Create and send answer
865
+ const answer = await peerConnection.createAnswer()
866
+ await peerConnection.setLocalDescription(answer)
867
+
868
+ //console.log(`RTC: Sending answer to client ${client.ID}`);
869
+ this.send(client, {
870
+ c: 'rtc-answer',
871
+ type: answer.type,
872
+ sdp: answer.sdp,
873
+ })
874
+ } catch (error) {
875
+ console.error(
876
+ `RTC: Error processing offer from client ${client.ID}:`,
877
+ error
878
+ )
879
+ }
880
+ }
881
+
882
+ async _processICECandidate(client, data) {
883
+ //console.log(`RTC: Processing ICE candidate from client ${client.ID}`);
884
+ try {
885
+ if (client.peerConnection && data.candidate) {
886
+ await client.peerConnection.addIceCandidate(
887
+ new wrtc.RTCIceCandidate(data.candidate)
888
+ )
889
+ //console.log(`RTC: ICE candidate added successfully for client ${client.ID}`);
890
+ } else {
891
+ //console.warn(`RTC: Cannot add ICE candidate for client ${client.ID} - peerConnection not ready or candidate missing`);
892
+ }
893
+ } catch (error) {
894
+ console.error(`RTC: Error adding ICE candidate for client ${client.ID}`)
895
+ }
896
+ }
897
+
898
+ _clientRTCOpen(client) {
899
+ return client.dataChannel && client.dataChannel.readyState === 'open'
900
+ }
901
+
902
+ async sendRTC(client, message) {
903
+ let data = encode(message)
904
+ if (this.allowCompression) {
905
+ data = await compress(data)
906
+ }
907
+ this.stats.sendRTC += data.byteLength
908
+ this.stats._sendRTCUpdate += data.byteLength
909
+
910
+ let packages = this._splitRTCMessage(data)
911
+
912
+ if (this.simulateLatency) {
913
+ setTimeout(() => {
914
+ if (this._clientRTCOpen(client)) {
915
+ packages.forEach((p) => {
916
+ client.dataChannel.send(p)
917
+ })
918
+ }
919
+ }, this.simulateLatency)
920
+ } else {
921
+ if (this._clientRTCOpen(client)) {
922
+ packages.forEach((p) => {
923
+ client.dataChannel.send(p)
924
+ })
925
+ }
926
+ }
927
+ }
928
+
929
+ async broadcastRTC(message:any, clients = []) {
930
+ if (clients.length == 0) {
931
+ clients = this.clients
932
+ }
933
+ let t1 = Date.now()
934
+ let data = encode(message)
935
+ let dl = data.byteLength
936
+ let t2 = Date.now()
937
+ if (this.allowCompression) {
938
+ data = await compress(data)
939
+ }
940
+ let t3 = Date.now()
941
+
942
+
943
+ if (data.length > 16384) {
944
+ console.log(`BroadcastRTC message ${dl} -> ${data.length} (${(100.0 * data.length / dl).toFixed()}%) encoding:${t2 - t1}ms compression:${t3 - t1}ms`)
945
+ }
946
+
947
+ let packages = this._splitRTCMessage(data)
948
+
949
+ for (let client of this.clients) {
950
+ this.stats.sendRTC += data.byteLength
951
+ this.stats._sendRTCUpdate += data.byteLength
952
+ if (this.simulateLatency) {
953
+ setTimeout(() => {
954
+ if (client.dataChannel && client.dataChannel.readyState === 'open') {
955
+ packages.forEach((p) => {
956
+ client.dataChannel.send(p)
957
+ })
958
+ }
959
+ }, this.simulateLatency)
960
+ } else {
961
+ if (client.dataChannel && client.dataChannel.readyState === 'open') {
962
+ packages.forEach((p) => {
963
+ client.dataChannel.send(p)
964
+ })
965
+ }
966
+ }
967
+ }
968
+ }
969
+
970
+ _splitRTCMessage(data) {
971
+ let packages
972
+ if (data.byteLength > 65535) {
973
+ const now = Date.now()
974
+ console.warn(`RTC: Message too large: ${data.byteLength} bytes`)
975
+ // Split the message into smaller packages
976
+ packages = [];
977
+ let offset = 0;
978
+ let mid = this.update +'-'+ now
979
+ let seq = 0
980
+
981
+ // Create subsequent packages if needed
982
+ while (offset < data.byteLength) {
983
+ const remaining = data.byteLength - offset;
984
+ const chunkSize = Math.min(remaining, MAX_PACKAGE_SIZE);
985
+ const chunk = new Uint8Array(data.buffer, offset, chunkSize);
986
+ let cmessage = {
987
+ c: 'chunk',
988
+ t: now,
989
+ mid: mid,
990
+ seq: seq,
991
+ ofs: offset,
992
+ chs: chunkSize,
993
+ ts: data.byteLength,
994
+ data: chunk,
995
+ last: remaining <= MAX_PACKAGE_SIZE,
996
+ }
997
+ packages.push(encode(cmessage))
998
+ offset += chunkSize;
999
+ seq++;
1000
+ }
1001
+
1002
+ console.log(`RTC: Large message split into ${packages.length} packages`);
1003
+ } else {
1004
+ packages = [data]
1005
+ console.log(`RTC: Message - ${data.byteLength} bytes`)
1006
+ }
1007
+ return packages
1008
+ }
1009
+
1010
+ /*= DATABASE =================================================================*/
1011
+ // properties (of the documents) that starts with __ are not saved to the database.
1012
+ // __properties are restored on hydration. (for example __physicsBody or __bigObject)
1013
+
1014
+ getUID() {
1015
+ this.documents['_server'].nextUID++
1016
+ return this.documents['_server'].nextUID
1017
+ }
1018
+
1019
+ async _initDB() {
1020
+ await this._connectDB()
1021
+ await this._loadDocument('_server')
1022
+ if (!this.documents['_server']) {
1023
+ this._initServerDocument()
1024
+ }
1025
+ }
1026
+
1027
+ async _connectDB() {
1028
+ this.mongoClient = new MongoClient(this.MongoUrl)
1029
+ try {
1030
+ await this.mongoClient.connect()
1031
+ console.log('Connected to MongoDB')
1032
+ const db = this.mongoClient.db(this.database)
1033
+ this.DB = db
1034
+ } catch (error) {
1035
+ console.error('Error connecting to MongoDB:', error)
1036
+ this.mongoClient = null
1037
+ }
1038
+ }
1039
+
1040
+ async _loadDocument(name) {
1041
+ console.log(`Loading document '${name}' from MongoDB`)
1042
+ if (this.DB) {
1043
+ try {
1044
+ const doc = await this.DB.collection(this.collection).findOne({
1045
+ name: name,
1046
+ })
1047
+ if (doc) {
1048
+ delete doc._id
1049
+ this.documents[name] = doc
1050
+ }
1051
+ } catch (error) {
1052
+ console.error('Error loading document from MongoDB:', error)
1053
+ }
1054
+ } else {
1055
+ console.warn('MongoDB client not initialized. Document not loaded.')
1056
+ }
1057
+ }
1058
+
1059
+ async _saveDocument(name) {
1060
+ if (this.DB) {
1061
+ try {
1062
+ const doc = this.documents[name]
1063
+ let newdoc = clonewo_(doc, '__')
1064
+ console.log(`Saving document '${name}' to MongoDB`)
1065
+ await this.DB.collection(this.collection).updateOne(
1066
+ { name: name },
1067
+ { $set: newdoc },
1068
+ { upsert: true }
1069
+ )
1070
+ console.log('Document saved to MongoDB')
1071
+ } catch (error) {
1072
+ console.error('Error saving document to MongoDB:', error)
1073
+ }
1074
+ } else {
1075
+ console.warn('MongoDB client not initialized. Document not saved.')
1076
+ }
1077
+ }
1078
+
1079
+ async _saveAllDocuments() {
1080
+ if (!this.allowSave) {
1081
+ return
1082
+ }
1083
+ for (let name in this.documents) {
1084
+ await this._saveDocument(name)
1085
+ }
1086
+ }
1087
+
1088
+ _initServerDocument() {
1089
+ this.documents['_server'] = {
1090
+ nextUID: 100,
1091
+ }
1092
+ }
1093
+
1094
+ /*= EXIT ===================================================================*/
1095
+
1096
+ _exitSignal(signal) {
1097
+ if (!this._exited) {
1098
+ console.log('\nEXIT: Caught interrupt signal ' + signal)
1099
+ this._exited = true
1100
+ clearInterval(this._loopiv)
1101
+ this.onBeforeExit()
1102
+ this.broadcast({ server: 'Going down' })
1103
+ this._saveAllDocuments()
1104
+ this._wss.close()
1105
+ setTimeout(() => process.exit(0), 1000)
1106
+ }
1107
+ }
1108
+
1109
+ // To be redefined. Called BEFORE program exit, and saving all documents
1110
+ onBeforeExit() {}
1111
+ }