openrtc-netcode 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/dist/client.js ADDED
@@ -0,0 +1,1184 @@
1
+ import { OpenRTC } from 'openrtc';
2
+ import { TypedEmitter } from './events.js';
3
+ import { decodeEnvelope, encodeEnvelope, isRecord, NETCODE_PROTOCOL, NETCODE_PROTOCOL_VERSION, } from './protocol.js';
4
+ import { isNetcodeObjectState, ReplicatedObjectStore } from './objects.js';
5
+ import { ReplicatedState } from './state.js';
6
+ import { NetcodeVoiceController } from './voice.js';
7
+ const DEFAULT_MAX_PEERS = 8;
8
+ const NETCODE_CHANNEL_ID = 'openrtc-netcode/v1';
9
+ const PING_INTERVAL_MS = 2000;
10
+ const MAX_SEEN_ENVELOPES = 2048;
11
+ const CHANNEL_OPEN_TIMEOUT_MS = 15000;
12
+ const UPGRADED_PAYLOAD_TRANSPORTS = new Set(['webrtc', 'webrtc-lan', 'moq']);
13
+ const textEncoder = new TextEncoder();
14
+ const textDecoder = new TextDecoder();
15
+ export class OpenRtcNetcodeClient {
16
+ constructor(options = {}) {
17
+ this.options = options;
18
+ this.runtime = null;
19
+ this.ownsRuntime = false;
20
+ this.localPeerId = null;
21
+ this.currentLobby = null;
22
+ this.currentRoomId = null;
23
+ this.fallbackChannel = null;
24
+ this.lobbyMetadata = {};
25
+ this.memberMetadata = {};
26
+ this.seq = 0;
27
+ this.started = false;
28
+ this.stopped = false;
29
+ this.stopWatchingRoom = null;
30
+ this.stopRuntimeConnections = null;
31
+ this.stopIncomingNetcodeChannel = null;
32
+ this.peerPingTimer = null;
33
+ this.emitter = new TypedEmitter();
34
+ this.messageListeners = new Map();
35
+ this.peersById = new Map();
36
+ this.peerStatsById = new Map();
37
+ this.connectionsByPeer = new Map();
38
+ this.channelsByPeer = new Map();
39
+ this.pendingChannelsByPeer = new Map();
40
+ this.connectionIds = new Set();
41
+ this.pendingPeerIds = new Set();
42
+ this.directSendWarnings = new Set();
43
+ this.requestedWebRtcUpgradeConnectionIds = new Set();
44
+ this.introPeerIds = new Set();
45
+ this.seenEnvelopeIds = new Set();
46
+ this.seenEnvelopeOrder = [];
47
+ this.stateStore = new ReplicatedState((options) => this.resolveStateOwner(options), (key, value, owner) => this.broadcastEnvelope('state.patch', { key, value, owner }));
48
+ this.objectStore = new ReplicatedObjectStore(() => this.localPeerId, (object) => this.broadcastEnvelope('object.upsert', { object }), (id) => this.broadcastEnvelope('object.remove', { id }));
49
+ this.voiceController = new NetcodeVoiceController(() => this.localPeerId, () => this.peers, shouldInitiateConnection, (peerId, signal) => this.sendEnvelope(peerId, 'voice.signal', { signal }));
50
+ }
51
+ get peerId() {
52
+ return this.localPeerId;
53
+ }
54
+ get lobby() {
55
+ return this.currentLobby ? cloneLobby(this.currentLobby) : null;
56
+ }
57
+ get peers() {
58
+ return Array.from(this.peersById.values()).map(clonePeer);
59
+ }
60
+ get peerStats() {
61
+ return Array.from(this.peerStatsById.values()).map(clonePeerStats);
62
+ }
63
+ get state() {
64
+ return this.stateStore;
65
+ }
66
+ get objects() {
67
+ return this.objectStore;
68
+ }
69
+ get voice() {
70
+ return this.voiceController;
71
+ }
72
+ async start() {
73
+ if (this.started) {
74
+ return;
75
+ }
76
+ this.started = true;
77
+ this.stopped = false;
78
+ this.runtime = this.options.runtime ?? OpenRTC({
79
+ apiKey: this.options.apiKey,
80
+ projectId: this.options.projectId,
81
+ authMode: this.options.authMode,
82
+ discoveryMode: this.options.discoveryMode,
83
+ allowAnonymousHostedDefaults: this.options.allowAnonymousHostedDefaults,
84
+ space: this.options.space,
85
+ spaceKey: this.options.spaceKey,
86
+ spaceTokenProvider: this.options.spaceTokenProvider,
87
+ storagePrefix: this.options.storagePrefix,
88
+ nodeIdPersistence: this.options.nodeIdPersistence,
89
+ transports: this.options.transports,
90
+ transportPriority: this.options.transportPriority,
91
+ strictMode: this.options.strictMode,
92
+ turnCredentialsProvider: this.options.turnCredentialsProvider,
93
+ });
94
+ this.ownsRuntime = !this.options.runtime;
95
+ await callOptional(this.runtime, 'initialize');
96
+ await callOptional(this.runtime, 'start');
97
+ this.localPeerId = await this.runtime.getNodeId();
98
+ this.stateStore.replaceOwner('local', this.localPeerId);
99
+ this.registerNetcodeChannel();
100
+ this.registerIncomingNetcodeChannel();
101
+ this.stopRuntimeConnections = this.runtime.onConnection((connection) => {
102
+ this.attachConnection(connection);
103
+ });
104
+ for (const connection of this.runtime.getConnections()) {
105
+ this.attachConnection(connection);
106
+ }
107
+ }
108
+ async stop() {
109
+ this.stopped = true;
110
+ await this.leaveLobby();
111
+ this.stopRuntimeConnections?.();
112
+ this.stopRuntimeConnections = null;
113
+ this.stopIncomingNetcodeChannel?.();
114
+ this.stopIncomingNetcodeChannel = null;
115
+ await this.closeNetcodeChannels();
116
+ for (const connection of this.connectionsByPeer.values()) {
117
+ await connection.disconnect?.();
118
+ }
119
+ this.connectionsByPeer.clear();
120
+ this.connectionIds.clear();
121
+ this.pendingPeerIds.clear();
122
+ this.directSendWarnings.clear();
123
+ this.requestedWebRtcUpgradeConnectionIds.clear();
124
+ this.introPeerIds.clear();
125
+ this.peerStatsById.clear();
126
+ this.seenEnvelopeIds.clear();
127
+ this.seenEnvelopeOrder.length = 0;
128
+ this.stopPeerPingLoop();
129
+ this.peersById.clear();
130
+ this.objectStore.clear();
131
+ await this.voiceController.stop();
132
+ this.emitter.clear();
133
+ this.messageListeners.clear();
134
+ if (this.ownsRuntime) {
135
+ await callOptional(this.runtime, 'stop');
136
+ }
137
+ this.runtime = null;
138
+ this.started = false;
139
+ }
140
+ async createLobby(options = {}) {
141
+ await this.start();
142
+ const runtime = this.requireRuntime();
143
+ await this.leaveLobby();
144
+ const lobbyId = await runtime.createRoom(options.id);
145
+ this.lobbyMetadata = { ...(options.metadata ?? {}) };
146
+ return this.activateLobby(lobbyId);
147
+ }
148
+ async joinLobby(lobbyId, options = {}) {
149
+ await this.start();
150
+ const runtime = this.requireRuntime();
151
+ await this.leaveLobby();
152
+ const roomId = normalizeLobbyId(lobbyId);
153
+ this.memberMetadata = { ...(options.metadata ?? {}) };
154
+ await runtime.joinRoom(roomId, { bootstrapPeers: false });
155
+ return this.activateLobby(roomId);
156
+ }
157
+ async joinOrCreateLobby(lobbyId, options = {}) {
158
+ try {
159
+ return await this.joinLobby(lobbyId, options);
160
+ }
161
+ catch (error) {
162
+ if (!isRoomNotFoundError(error)) {
163
+ throw error;
164
+ }
165
+ const runtime = this.requireRuntime();
166
+ const roomId = normalizeLobbyId(lobbyId);
167
+ this.memberMetadata = { ...(options.metadata ?? {}) };
168
+ try {
169
+ const createdRoomId = await runtime.createRoom(roomId);
170
+ return this.activateLobby(createdRoomId);
171
+ }
172
+ catch (createError) {
173
+ if (!isRoomAlreadyExistsError(createError)) {
174
+ throw createError;
175
+ }
176
+ }
177
+ return this.joinLobbyWithRetry(roomId, options);
178
+ }
179
+ }
180
+ async activateLobby(lobbyId) {
181
+ const runtime = this.requireRuntime();
182
+ const roomId = normalizeLobbyId(lobbyId);
183
+ this.currentRoomId = roomId;
184
+ this.openFallbackChannel(roomId);
185
+ this.startPeerPingLoop();
186
+ this.stopWatchingRoom = runtime.watchRoom(roomId, (members) => {
187
+ void this.handleRoomMembers(members);
188
+ });
189
+ await this.handleRoomMembers(await this.getRoomMembersWithRetry(roomId));
190
+ await this.broadcastMemberMetadata();
191
+ return this.requireLobby();
192
+ }
193
+ async joinLobbyWithRetry(lobbyId, options) {
194
+ let lastError;
195
+ for (let attempt = 0; attempt < 6; attempt += 1) {
196
+ try {
197
+ return await this.joinLobby(lobbyId, options);
198
+ }
199
+ catch (error) {
200
+ lastError = error;
201
+ if (!isRoomAdmissionTransientError(error) || attempt === 5) {
202
+ break;
203
+ }
204
+ await delay(150 * (attempt + 1));
205
+ }
206
+ }
207
+ throw lastError;
208
+ }
209
+ async getRoomMembersWithRetry(roomId) {
210
+ const runtime = this.requireRuntime();
211
+ let lastError;
212
+ for (let attempt = 0; attempt < 6; attempt += 1) {
213
+ try {
214
+ return await runtime.getRoomMembers(roomId);
215
+ }
216
+ catch (error) {
217
+ lastError = error;
218
+ if (!isRoomNotFoundError(error) || attempt === 5) {
219
+ break;
220
+ }
221
+ await delay(150 * (attempt + 1));
222
+ }
223
+ }
224
+ throw lastError;
225
+ }
226
+ async leaveLobby() {
227
+ const runtime = this.runtime;
228
+ const roomId = this.currentRoomId;
229
+ this.stopWatchingRoom?.();
230
+ this.stopWatchingRoom = null;
231
+ this.currentRoomId = null;
232
+ this.stopPeerPingLoop();
233
+ this.closeFallbackChannel();
234
+ await this.closeNetcodeChannels();
235
+ this.currentLobby = null;
236
+ this.peersById.clear();
237
+ this.pendingPeerIds.clear();
238
+ this.introPeerIds.clear();
239
+ this.peerStatsById.clear();
240
+ this.seenEnvelopeIds.clear();
241
+ this.seenEnvelopeOrder.length = 0;
242
+ this.objectStore.clear();
243
+ await this.voiceController.stop();
244
+ if (runtime && roomId) {
245
+ await runtime.leaveRoom(roomId);
246
+ }
247
+ this.emitter.emit('lobby', null);
248
+ }
249
+ async send(peerId, type, payload, options = {}) {
250
+ assertReliable(options);
251
+ await this.sendEnvelope(peerId, 'user', { type, payload });
252
+ }
253
+ async broadcast(type, payload, options = {}) {
254
+ assertReliable(options);
255
+ await this.broadcastEnvelope('user', { type, payload });
256
+ }
257
+ on(event, handler) {
258
+ return this.emitter.on(event, handler);
259
+ }
260
+ onMessage(type, handler) {
261
+ const listeners = this.messageListeners.get(type) ?? new Set();
262
+ const listener = handler;
263
+ listeners.add(listener);
264
+ this.messageListeners.set(type, listeners);
265
+ return () => listeners.delete(listener);
266
+ }
267
+ async setLobbyData(key, value) {
268
+ this.lobbyMetadata = { ...this.lobbyMetadata, [key]: value };
269
+ this.refreshLobby();
270
+ await this.broadcastEnvelope('lobby.meta', { values: this.lobbyMetadata });
271
+ }
272
+ async setMemberData(key, value) {
273
+ this.memberMetadata = { ...this.memberMetadata, [key]: value };
274
+ await this.broadcastMemberMetadata();
275
+ }
276
+ async handleRoomMembers(members) {
277
+ if (this.stopped || !this.localPeerId || !this.currentRoomId) {
278
+ return;
279
+ }
280
+ const activeMembers = members.filter(hasActiveMember);
281
+ const nextPeerIds = new Set();
282
+ for (const member of activeMembers) {
283
+ const peerId = member.nodeId;
284
+ if (!peerId || peerId === this.localPeerId) {
285
+ continue;
286
+ }
287
+ nextPeerIds.add(peerId);
288
+ const previous = this.peersById.get(peerId);
289
+ const peer = {
290
+ id: peerId,
291
+ userId: member.userId,
292
+ ticket: member.ticket,
293
+ joinedAt: member.joinedAt,
294
+ connected: this.isPeerPayloadReady(peerId),
295
+ metadata: previous?.metadata ?? parseMetadata(member.metadata),
296
+ stats: this.peerStatsById.get(peerId),
297
+ };
298
+ this.peersById.set(peerId, peer);
299
+ this.updatePeerStats(peerId, {
300
+ connected: peer.connected,
301
+ transport: this.resolvePeerTransport(peerId),
302
+ });
303
+ if (!previous) {
304
+ this.emitter.emit('peer:joined', clonePeer(peer));
305
+ if (this.fallbackChannel) {
306
+ this.queuePeerIntro(peerId);
307
+ }
308
+ }
309
+ }
310
+ for (const [peerId, peer] of Array.from(this.peersById.entries())) {
311
+ if (!nextPeerIds.has(peerId)) {
312
+ if (this.fallbackChannel) {
313
+ continue;
314
+ }
315
+ const connection = this.connectionsByPeer.get(peerId);
316
+ this.peersById.delete(peerId);
317
+ this.connectionsByPeer.delete(peerId);
318
+ this.pendingPeerIds.delete(peerId);
319
+ if (connection?.id) {
320
+ this.requestedWebRtcUpgradeConnectionIds.delete(connection.id);
321
+ }
322
+ this.introPeerIds.delete(peerId);
323
+ this.peerStatsById.delete(peerId);
324
+ this.emitter.emit('peer:left', clonePeer(peer));
325
+ this.voiceController.handlePeerDisconnected(peerId);
326
+ }
327
+ }
328
+ this.refreshLobby(activeMembers);
329
+ void this.reconcileMesh();
330
+ }
331
+ async reconcileMesh() {
332
+ if (!this.runtime || !this.localPeerId) {
333
+ return;
334
+ }
335
+ const maxPeers = this.options.maxPeers ?? DEFAULT_MAX_PEERS;
336
+ for (const peer of this.peersById.values()) {
337
+ if (this.connectionsByPeer.size >= maxPeers) {
338
+ return;
339
+ }
340
+ if (!peer.ticket || !shouldInitiateConnection(this.localPeerId, peer.id)) {
341
+ continue;
342
+ }
343
+ if (this.connectionsByPeer.has(peer.id) || this.pendingPeerIds.has(peer.id)) {
344
+ continue;
345
+ }
346
+ this.pendingPeerIds.add(peer.id);
347
+ try {
348
+ const connection = await this.runtime.connectByTicket({ ticket: peer.ticket });
349
+ this.attachConnection(connection, peer.id);
350
+ await this.sendPeerIntro(peer.id, true);
351
+ }
352
+ catch (error) {
353
+ console.warn('[openrtc-netcode] peer dial failed; leaving peer disconnected', error);
354
+ }
355
+ finally {
356
+ this.pendingPeerIds.delete(peer.id);
357
+ }
358
+ }
359
+ }
360
+ attachConnection(connection, peerIdHint) {
361
+ const connectionId = connection.id;
362
+ if (connectionId && this.connectionIds.has(connectionId)) {
363
+ return;
364
+ }
365
+ const peerId = peerIdHint ?? connection.remoteNodeId ?? connection.deviceId;
366
+ if (!peerId || peerId === this.localPeerId) {
367
+ return;
368
+ }
369
+ if (connectionId) {
370
+ this.connectionIds.add(connectionId);
371
+ }
372
+ this.connectionsByPeer.set(peerId, connection);
373
+ this.requestPreferredTransportUpgrade(connection);
374
+ const payloadReady = this.isPeerPayloadReady(peerId);
375
+ const peer = this.upsertPeer(peerId, { connected: payloadReady });
376
+ this.updatePeerStats(peerId, {
377
+ connected: payloadReady,
378
+ transport: this.resolvePeerTransport(peerId),
379
+ });
380
+ if (payloadReady) {
381
+ this.emitter.emit('peer:connected', clonePeer(peer));
382
+ void this.voiceController.handlePeerConnected(peerId);
383
+ }
384
+ connection.onMessage((message) => {
385
+ this.handleConnectionMessage(peerId, message);
386
+ });
387
+ connection.onDisconnect(() => {
388
+ if (this.connectionsByPeer.get(peerId) === connection) {
389
+ this.connectionsByPeer.delete(peerId);
390
+ }
391
+ if (connectionId) {
392
+ this.connectionIds.delete(connectionId);
393
+ this.requestedWebRtcUpgradeConnectionIds.delete(connectionId);
394
+ }
395
+ const disconnected = this.upsertPeer(peerId, { connected: false });
396
+ this.updatePeerStats(peerId, {
397
+ connected: false,
398
+ transport: this.resolvePeerTransport(peerId),
399
+ });
400
+ this.emitter.emit('peer:disconnected', clonePeer(disconnected));
401
+ this.voiceController.handlePeerDisconnected(peerId);
402
+ });
403
+ if (payloadReady) {
404
+ this.queuePeerIntro(peerId, true);
405
+ }
406
+ if (payloadReady && this.currentRoomId) {
407
+ void this.sendEnvelope(peerId, 'ping', {});
408
+ }
409
+ }
410
+ async sendPeerIntro(peerId, force = false) {
411
+ if (!this.localPeerId) {
412
+ return;
413
+ }
414
+ if (!this.currentRoomId || (!force && this.introPeerIds.has(peerId))) {
415
+ return;
416
+ }
417
+ this.introPeerIds.add(peerId);
418
+ try {
419
+ await this.sendEnvelope(peerId, 'hello', {
420
+ peerId: this.localPeerId,
421
+ displayName: this.options.displayName,
422
+ });
423
+ await this.sendStateSnapshot(peerId);
424
+ await this.sendObjectSnapshot(peerId);
425
+ await this.sendEnvelope(peerId, 'lobby.meta', { values: this.lobbyMetadata });
426
+ await this.sendEnvelope(peerId, 'member.meta', { values: this.memberMetadata });
427
+ }
428
+ catch (error) {
429
+ this.introPeerIds.delete(peerId);
430
+ throw error;
431
+ }
432
+ }
433
+ queuePeerIntro(peerId, force = false) {
434
+ queueTask(() => {
435
+ void this.sendPeerIntro(peerId, force).catch((error) => {
436
+ if (this.currentRoomId) {
437
+ console.warn('[openrtc-netcode] peer intro failed', error);
438
+ }
439
+ });
440
+ });
441
+ }
442
+ handleConnectionMessage(peerId, message) {
443
+ const envelope = decodeEnvelope(message);
444
+ if (!envelope || envelope.lobbyId !== this.currentRoomId) {
445
+ return;
446
+ }
447
+ if (envelope.from === this.localPeerId) {
448
+ return;
449
+ }
450
+ if (envelope.target && envelope.target !== this.localPeerId) {
451
+ return;
452
+ }
453
+ if (!this.markEnvelopeSeen(envelope)) {
454
+ return;
455
+ }
456
+ this.upsertPeer(peerId, { connected: true });
457
+ this.updatePeerStats(peerId, {
458
+ connected: true,
459
+ transport: this.resolvePeerTransport(peerId),
460
+ });
461
+ this.emitter.emit('envelope', envelope);
462
+ switch (envelope.kind) {
463
+ case 'hello':
464
+ this.handleHello(peerId, envelope.body);
465
+ break;
466
+ case 'user':
467
+ this.handleUserMessage(envelope);
468
+ break;
469
+ case 'state.snapshot':
470
+ this.handleStateSnapshot(envelope.body);
471
+ break;
472
+ case 'state.patch':
473
+ this.handleStatePatch(envelope.body);
474
+ break;
475
+ case 'object.snapshot':
476
+ this.handleObjectSnapshot(envelope.from, envelope.body);
477
+ break;
478
+ case 'object.upsert':
479
+ this.handleObjectUpsert(envelope.from, envelope.body);
480
+ break;
481
+ case 'object.remove':
482
+ this.handleObjectRemove(envelope.from, envelope.body);
483
+ break;
484
+ case 'lobby.meta':
485
+ this.handleLobbyMetadata(envelope.body);
486
+ break;
487
+ case 'member.meta':
488
+ this.handleMemberMetadata(peerId, envelope.body);
489
+ break;
490
+ case 'voice.signal':
491
+ this.handleVoiceSignal(peerId, envelope.body);
492
+ break;
493
+ case 'ping':
494
+ void this.sendEnvelope(peerId, 'pong', { pingSentAt: envelope.sentAt });
495
+ break;
496
+ case 'peer':
497
+ break;
498
+ case 'pong':
499
+ this.handlePong(peerId, envelope.body);
500
+ break;
501
+ }
502
+ }
503
+ markEnvelopeSeen(envelope) {
504
+ const id = `${envelope.from}:${envelope.seq}`;
505
+ if (this.seenEnvelopeIds.has(id)) {
506
+ return false;
507
+ }
508
+ this.seenEnvelopeIds.add(id);
509
+ this.seenEnvelopeOrder.push(id);
510
+ while (this.seenEnvelopeOrder.length > MAX_SEEN_ENVELOPES) {
511
+ const oldest = this.seenEnvelopeOrder.shift();
512
+ if (oldest) {
513
+ this.seenEnvelopeIds.delete(oldest);
514
+ }
515
+ }
516
+ return true;
517
+ }
518
+ handleHello(peerId, body) {
519
+ if (!isRecord(body)) {
520
+ return;
521
+ }
522
+ const displayName = typeof body.displayName === 'string' ? body.displayName : undefined;
523
+ this.upsertPeer(peerId, {
524
+ connected: true,
525
+ metadata: displayName ? { displayName } : undefined,
526
+ });
527
+ this.queuePeerIntro(peerId);
528
+ }
529
+ handleUserMessage(envelope) {
530
+ const body = envelope.body;
531
+ if (!body || typeof body.type !== 'string') {
532
+ return;
533
+ }
534
+ const message = {
535
+ type: body.type,
536
+ payload: body.payload,
537
+ from: envelope.from,
538
+ target: envelope.target,
539
+ sentAt: envelope.sentAt,
540
+ };
541
+ this.emitter.emit('message', message);
542
+ for (const listener of this.messageListeners.get(body.type) ?? []) {
543
+ listener(message);
544
+ }
545
+ }
546
+ handleStateSnapshot(body) {
547
+ if (!isRecord(body) || !isRecord(body.values) || !isRecord(body.owners)) {
548
+ return;
549
+ }
550
+ const snapshot = {
551
+ values: body.values,
552
+ owners: stringifyRecord(body.owners),
553
+ };
554
+ this.stateStore.applySnapshot(snapshot.values, snapshot.owners);
555
+ this.emitter.emit('state:snapshot', snapshot);
556
+ }
557
+ handleStatePatch(body) {
558
+ if (!isRecord(body) || typeof body.key !== 'string' || typeof body.owner !== 'string') {
559
+ return;
560
+ }
561
+ const patch = {
562
+ key: body.key,
563
+ value: body.value,
564
+ owner: body.owner,
565
+ };
566
+ this.stateStore.applyPatch(patch.key, patch.value, patch.owner);
567
+ this.emitter.emit('state:patch', patch);
568
+ }
569
+ handleObjectSnapshot(from, body) {
570
+ if (!isRecord(body) || !isRecord(body.objects)) {
571
+ return;
572
+ }
573
+ const objects = {};
574
+ for (const [id, object] of Object.entries(body.objects)) {
575
+ if (isNetcodeObjectState(object)) {
576
+ objects[id] = object;
577
+ }
578
+ }
579
+ const snapshot = { objects };
580
+ this.objectStore.applySnapshot(objects, from);
581
+ this.emitter.emit('object:snapshot', snapshot);
582
+ }
583
+ handleObjectUpsert(from, body) {
584
+ if (!isRecord(body) || !isNetcodeObjectState(body.object)) {
585
+ return;
586
+ }
587
+ const upsert = { object: body.object };
588
+ this.objectStore.applyUpsert(body.object, from);
589
+ this.emitter.emit('object:upsert', upsert);
590
+ }
591
+ handleObjectRemove(from, body) {
592
+ if (!isRecord(body) || typeof body.id !== 'string') {
593
+ return;
594
+ }
595
+ const remove = { id: body.id };
596
+ this.objectStore.applyRemove(remove.id, from);
597
+ this.emitter.emit('object:remove', remove);
598
+ }
599
+ handleLobbyMetadata(body) {
600
+ if (!isRecord(body) || !isRecord(body.values)) {
601
+ return;
602
+ }
603
+ const metadata = { values: stringifyRecord(body.values) };
604
+ this.lobbyMetadata = metadata.values;
605
+ this.refreshLobby();
606
+ this.emitter.emit('lobby:metadata', metadata);
607
+ }
608
+ handleMemberMetadata(peerId, body) {
609
+ if (!isRecord(body) || !isRecord(body.values)) {
610
+ return;
611
+ }
612
+ const metadata = { values: stringifyRecord(body.values) };
613
+ this.upsertPeer(peerId, { metadata: metadata.values });
614
+ this.emitter.emit('member:metadata', { peerId, values: metadata.values });
615
+ }
616
+ handleVoiceSignal(peerId, body) {
617
+ if (!isRecord(body) || !isRecord(body.signal)) {
618
+ return;
619
+ }
620
+ const signal = { signal: body.signal };
621
+ this.emitter.emit('voice:signal', { peerId, ...signal });
622
+ void this.voiceController.handleSignal(peerId, signal.signal);
623
+ }
624
+ handlePong(peerId, body) {
625
+ if (!isRecord(body) || typeof body.pingSentAt !== 'number') {
626
+ return;
627
+ }
628
+ const latencyMs = Math.max(0, Date.now() - body.pingSentAt);
629
+ this.updatePeerStats(peerId, {
630
+ latencyMs,
631
+ connected: true,
632
+ transport: this.resolvePeerTransport(peerId),
633
+ });
634
+ }
635
+ async sendStateSnapshot(peerId) {
636
+ await this.sendEnvelope(peerId, 'state.snapshot', {
637
+ values: this.stateStore.snapshot(),
638
+ owners: this.stateStore.owners(),
639
+ });
640
+ }
641
+ async sendObjectSnapshot(peerId) {
642
+ await this.sendEnvelope(peerId, 'object.snapshot', {
643
+ objects: this.objectStore.snapshot(),
644
+ });
645
+ }
646
+ async broadcastMemberMetadata() {
647
+ await this.broadcastEnvelope('member.meta', { values: this.memberMetadata });
648
+ }
649
+ startPeerPingLoop() {
650
+ this.stopPeerPingLoop();
651
+ this.peerPingTimer = setInterval(() => {
652
+ if (!this.currentRoomId || !this.localPeerId) {
653
+ return;
654
+ }
655
+ for (const peer of this.peersById.values()) {
656
+ const payloadReady = this.isPeerPayloadReady(peer.id);
657
+ if (!payloadReady) {
658
+ const connection = this.connectionsByPeer.get(peer.id);
659
+ if (connection) {
660
+ this.requestPreferredTransportUpgrade(connection);
661
+ }
662
+ }
663
+ if (payloadReady && !peer.connected) {
664
+ const connected = this.upsertPeer(peer.id, { connected: true });
665
+ this.emitter.emit('peer:connected', clonePeer(connected));
666
+ void this.voiceController.handlePeerConnected(peer.id);
667
+ this.queuePeerIntro(peer.id, true);
668
+ }
669
+ if (payloadReady) {
670
+ void this.sendEnvelope(peer.id, 'ping', {});
671
+ }
672
+ this.updatePeerStats(peer.id, {
673
+ connected: payloadReady,
674
+ transport: this.resolvePeerTransport(peer.id),
675
+ });
676
+ }
677
+ }, PING_INTERVAL_MS);
678
+ }
679
+ stopPeerPingLoop() {
680
+ if (this.peerPingTimer) {
681
+ clearInterval(this.peerPingTimer);
682
+ this.peerPingTimer = null;
683
+ }
684
+ }
685
+ async broadcastEnvelope(kind, body) {
686
+ const envelope = this.createEnvelope(kind, body);
687
+ if (this.fallbackChannel) {
688
+ this.fallbackChannel.postMessage(encodeEnvelope(envelope));
689
+ }
690
+ const sends = Array.from(this.connectionsByPeer.keys()).map((peerId) => this.sendDirectEnvelope(peerId, envelope));
691
+ await Promise.all(sends);
692
+ }
693
+ async sendEnvelope(peerId, kind, body) {
694
+ const envelope = this.createEnvelope(kind, body, peerId);
695
+ if (this.fallbackChannel) {
696
+ this.fallbackChannel.postMessage(encodeEnvelope(envelope));
697
+ }
698
+ await this.sendDirectEnvelope(peerId, envelope);
699
+ }
700
+ async sendDirectEnvelope(peerId, envelope) {
701
+ const channel = await this.resolveNetcodeChannel(peerId);
702
+ if (channel) {
703
+ await channel.send(textEncoder.encode(encodeEnvelope(envelope)));
704
+ return;
705
+ }
706
+ const connection = this.resolvePayloadConnection(peerId);
707
+ if (!connection || connection.isClosed) {
708
+ return;
709
+ }
710
+ let send;
711
+ try {
712
+ send = Promise.resolve(connection.send(encodeEnvelope(envelope)));
713
+ }
714
+ catch (error) {
715
+ if (this.fallbackChannel) {
716
+ this.warnDirectSendFailure(peerId, envelope.kind, error);
717
+ return;
718
+ }
719
+ throw error;
720
+ }
721
+ if (this.fallbackChannel) {
722
+ void send.catch((error) => this.warnDirectSendFailure(peerId, envelope.kind, error));
723
+ return;
724
+ }
725
+ await send;
726
+ }
727
+ warnDirectSendFailure(peerId, kind, error) {
728
+ const key = `${peerId}:${kind}`;
729
+ if (this.directSendWarnings.has(key)) {
730
+ return;
731
+ }
732
+ this.directSendWarnings.add(key);
733
+ console.warn('[openrtc-netcode] direct send failed; local dev fallback already published envelope', error);
734
+ }
735
+ openFallbackChannel(roomId) {
736
+ this.closeFallbackChannel();
737
+ if (this.options.localFallback !== true) {
738
+ return;
739
+ }
740
+ if (typeof globalThis.BroadcastChannel !== 'function') {
741
+ return;
742
+ }
743
+ const channel = new globalThis.BroadcastChannel(`openrtc-netcode:${roomId}`);
744
+ channel.onmessage = (event) => {
745
+ const envelope = decodeEnvelope(event.data);
746
+ if (!envelope || envelope.from === this.localPeerId) {
747
+ return;
748
+ }
749
+ this.handleConnectionMessage(envelope.from, envelope);
750
+ };
751
+ this.fallbackChannel = channel;
752
+ }
753
+ closeFallbackChannel() {
754
+ this.fallbackChannel?.close();
755
+ this.fallbackChannel = null;
756
+ }
757
+ async resolveNetcodeChannel(peerId) {
758
+ const existing = this.channelsByPeer.get(peerId);
759
+ if (existing) {
760
+ return existing;
761
+ }
762
+ const pending = this.pendingChannelsByPeer.get(peerId);
763
+ if (pending) {
764
+ return pending;
765
+ }
766
+ const runtime = this.runtime;
767
+ if (typeof runtime?.connectScopedChannel !== 'function') {
768
+ return null;
769
+ }
770
+ const ticket = await this.resolvePeerTicket(peerId);
771
+ if (!ticket) {
772
+ return null;
773
+ }
774
+ const opening = runtime.connectScopedChannel({
775
+ peerId,
776
+ ticket,
777
+ scope: NETCODE_CHANNEL_ID,
778
+ channelId: NETCODE_CHANNEL_ID,
779
+ timeoutMs: CHANNEL_OPEN_TIMEOUT_MS,
780
+ nativeLabel: true,
781
+ framing: 'u32be',
782
+ }).then((channel) => {
783
+ this.attachNetcodeChannel(peerId, channel);
784
+ return channel;
785
+ }).catch(() => null).finally(() => {
786
+ this.pendingChannelsByPeer.delete(peerId);
787
+ });
788
+ this.pendingChannelsByPeer.set(peerId, opening);
789
+ return opening;
790
+ }
791
+ async resolvePeerTicket(peerId) {
792
+ const existing = this.peersById.get(peerId)?.ticket?.trim();
793
+ if (existing) {
794
+ return existing;
795
+ }
796
+ if (!this.currentRoomId || !this.runtime) {
797
+ return undefined;
798
+ }
799
+ const members = await this.getRoomMembersWithRetry(this.currentRoomId).catch(() => []);
800
+ const member = members.find((candidate) => candidate.nodeId === peerId && candidate.ticket);
801
+ if (!member?.ticket) {
802
+ return undefined;
803
+ }
804
+ const previous = this.peersById.get(peerId);
805
+ this.peersById.set(peerId, {
806
+ id: peerId,
807
+ userId: member.userId ?? previous?.userId,
808
+ ticket: member.ticket,
809
+ joinedAt: member.joinedAt ?? previous?.joinedAt,
810
+ connected: previous?.connected ?? this.isPeerPayloadReady(peerId),
811
+ metadata: previous?.metadata ?? parseMetadata(member.metadata),
812
+ stats: previous?.stats ?? this.peerStatsById.get(peerId),
813
+ });
814
+ this.refreshLobby();
815
+ return member.ticket;
816
+ }
817
+ attachNetcodeChannel(peerId, channel) {
818
+ this.channelsByPeer.set(peerId, channel);
819
+ channel.onMessage((payload) => {
820
+ this.handleConnectionMessage(peerId, textDecoder.decode(payload));
821
+ });
822
+ const peer = this.upsertPeer(peerId, { connected: true });
823
+ this.updatePeerStats(peerId, {
824
+ connected: true,
825
+ transport: this.resolvePeerTransport(peerId),
826
+ });
827
+ this.emitter.emit('peer:connected', clonePeer(peer));
828
+ void this.voiceController.handlePeerConnected(peerId);
829
+ this.queuePeerIntro(peerId, true);
830
+ }
831
+ registerIncomingNetcodeChannel() {
832
+ const runtime = this.runtime;
833
+ if (typeof runtime?.onIncomingChannelStream !== 'function') {
834
+ return;
835
+ }
836
+ this.stopIncomingNetcodeChannel?.();
837
+ this.stopIncomingNetcodeChannel = runtime.onIncomingChannelStream(NETCODE_CHANNEL_ID, (incoming) => {
838
+ if (incoming.type !== 'bi' || !incoming.remoteNodeId || !incoming.stream) {
839
+ return false;
840
+ }
841
+ this.readIncomingNetcodeStream(incoming.remoteNodeId, incoming.stream);
842
+ return true;
843
+ });
844
+ }
845
+ readIncomingNetcodeStream(peerId, stream) {
846
+ const readable = stream.recv ?? stream.readable;
847
+ if (!readable) {
848
+ return;
849
+ }
850
+ void (async () => {
851
+ const reader = readable.getReader();
852
+ let buffered = new Uint8Array(0);
853
+ try {
854
+ while (!this.stopped) {
855
+ const { value, done } = await reader.read();
856
+ if (done) {
857
+ break;
858
+ }
859
+ if (!value?.byteLength) {
860
+ continue;
861
+ }
862
+ buffered = concatBytes(buffered, value);
863
+ while (buffered.byteLength >= 4) {
864
+ const length = readU32BE(buffered);
865
+ if (buffered.byteLength < length + 4) {
866
+ break;
867
+ }
868
+ const payload = buffered.slice(4, 4 + length);
869
+ buffered = buffered.slice(4 + length);
870
+ this.handleConnectionMessage(peerId, textDecoder.decode(payload));
871
+ }
872
+ }
873
+ }
874
+ finally {
875
+ reader.releaseLock();
876
+ }
877
+ })();
878
+ }
879
+ async closeNetcodeChannels() {
880
+ const channels = Array.from(this.channelsByPeer.values());
881
+ this.channelsByPeer.clear();
882
+ this.pendingChannelsByPeer.clear();
883
+ await Promise.allSettled(channels.map((channel) => channel.close()));
884
+ }
885
+ createEnvelope(kind, body, target) {
886
+ if (!this.currentRoomId || !this.localPeerId) {
887
+ throw new Error('openrtc-netcode requires a joined lobby before sending.');
888
+ }
889
+ return {
890
+ protocol: NETCODE_PROTOCOL,
891
+ v: NETCODE_PROTOCOL_VERSION,
892
+ kind,
893
+ lobbyId: this.currentRoomId,
894
+ from: this.localPeerId,
895
+ seq: ++this.seq,
896
+ sentAt: Date.now(),
897
+ target,
898
+ body,
899
+ };
900
+ }
901
+ resolveStateOwner(options) {
902
+ if (options?.owner === 'lobby-owner') {
903
+ return this.currentLobby?.ownerId ?? this.localPeerId ?? 'local';
904
+ }
905
+ if (options?.owner && options.owner !== 'local') {
906
+ return options.owner;
907
+ }
908
+ return this.localPeerId ?? 'local';
909
+ }
910
+ upsertPeer(peerId, update) {
911
+ const previous = this.peersById.get(peerId);
912
+ const { metadata, ...restUpdate } = update;
913
+ const peer = {
914
+ id: peerId,
915
+ connected: false,
916
+ ...previous,
917
+ ...restUpdate,
918
+ stats: this.peerStatsById.get(peerId),
919
+ metadata: {
920
+ ...(previous?.metadata ?? {}),
921
+ ...(metadata ?? {}),
922
+ },
923
+ };
924
+ this.peersById.set(peerId, peer);
925
+ this.refreshLobby();
926
+ return peer;
927
+ }
928
+ updatePeerStats(peerId, update) {
929
+ const previous = this.peerStatsById.get(peerId);
930
+ const stats = {
931
+ peerId,
932
+ transport: previous?.transport ?? 'unknown',
933
+ latencyMs: previous?.latencyMs ?? null,
934
+ connected: previous?.connected ?? false,
935
+ ...update,
936
+ updatedAt: Date.now(),
937
+ };
938
+ this.peerStatsById.set(peerId, stats);
939
+ const peer = this.peersById.get(peerId);
940
+ if (peer) {
941
+ this.peersById.set(peerId, { ...peer, stats });
942
+ this.refreshLobby();
943
+ }
944
+ this.emitter.emit('peer:stats', clonePeerStats(stats));
945
+ return stats;
946
+ }
947
+ resolvePeerTransport(peerId) {
948
+ const connection = this.resolvePayloadConnection(peerId);
949
+ const status = connection?.getTransportStatus?.();
950
+ if (status?.activeTransport) {
951
+ return status.parallelTransport
952
+ ? `${status.activeTransport}+${status.parallelTransport}`
953
+ : status.activeTransport;
954
+ }
955
+ const transports = connection?.getAvailableTransports?.();
956
+ if (transports && transports.length > 0) {
957
+ return transports.join('+');
958
+ }
959
+ return this.connectionsByPeer.has(peerId) ? 'connecting' : 'unknown';
960
+ }
961
+ isPeerPayloadReady(peerId) {
962
+ return this.channelsByPeer.has(peerId) || !!this.resolvePayloadConnection(peerId);
963
+ }
964
+ resolvePayloadConnection(peerId) {
965
+ const channel = this.channelsByPeer.get(peerId);
966
+ if (channel?.connection) {
967
+ return channel.connection;
968
+ }
969
+ const runtime = this.runtime;
970
+ if (typeof runtime?.getApplicationReadyConnections === 'function') {
971
+ return runtime.getApplicationReadyConnections(this.payloadReadinessOptions())
972
+ .find((connection) => connection.remoteNodeId === peerId || connection.deviceId === peerId)
973
+ ?? null;
974
+ }
975
+ return this.connectionsByPeer.get(peerId) ?? null;
976
+ }
977
+ requestPreferredTransportUpgrade(connection) {
978
+ const preferredTransports = this.payloadReadinessOptions().preferredTransports ?? [];
979
+ const wantsWebRtc = preferredTransports.some((transport) => transport === 'webrtc' || transport === 'webrtc-lan');
980
+ if (!wantsWebRtc || typeof connection.requestWebRTCUpgrade !== 'function') {
981
+ return;
982
+ }
983
+ const remotePeerId = connection.remoteNodeId ?? connection.deviceId;
984
+ if (this.localPeerId && remotePeerId && this.localPeerId.localeCompare(remotePeerId) <= 0) {
985
+ return;
986
+ }
987
+ const upgradeKey = connection.id ?? connection.remoteNodeId ?? connection.deviceId;
988
+ if (upgradeKey && this.requestedWebRtcUpgradeConnectionIds.has(upgradeKey)) {
989
+ return;
990
+ }
991
+ if (upgradeKey) {
992
+ this.requestedWebRtcUpgradeConnectionIds.add(upgradeKey);
993
+ }
994
+ connection.requestWebRTCUpgrade('openrtc-netcode-preferred-payload');
995
+ }
996
+ payloadReadinessOptions() {
997
+ const preferredTransports = this.options.preferredPayloadTransports
998
+ ?? derivePreferredPayloadTransports(this.options.transports, this.options.transportPriority);
999
+ if (preferredTransports.length === 0) {
1000
+ return {};
1001
+ }
1002
+ return {
1003
+ preferredTransports,
1004
+ allowFallbackAfterPreferredTransportFailure: true,
1005
+ };
1006
+ }
1007
+ refreshLobby(members) {
1008
+ if (!this.currentRoomId || !this.localPeerId) {
1009
+ return;
1010
+ }
1011
+ const ownerId = resolveOwnerId(members, this.localPeerId);
1012
+ this.currentLobby = {
1013
+ id: this.currentRoomId,
1014
+ localPeerId: this.localPeerId,
1015
+ ownerId,
1016
+ members: [
1017
+ {
1018
+ id: this.localPeerId,
1019
+ connected: true,
1020
+ metadata: { ...this.memberMetadata },
1021
+ },
1022
+ ...this.peers,
1023
+ ],
1024
+ metadata: { ...this.lobbyMetadata },
1025
+ };
1026
+ this.emitter.emit('lobby', cloneLobby(this.currentLobby));
1027
+ }
1028
+ requireRuntime() {
1029
+ if (!this.runtime) {
1030
+ throw new Error('openrtc-netcode runtime is not started.');
1031
+ }
1032
+ return this.runtime;
1033
+ }
1034
+ requireLobby() {
1035
+ if (!this.currentLobby) {
1036
+ throw new Error('openrtc-netcode is not in a lobby.');
1037
+ }
1038
+ return cloneLobby(this.currentLobby);
1039
+ }
1040
+ registerNetcodeChannel() {
1041
+ const maybeChannelClient = this.runtime;
1042
+ maybeChannelClient.channels?.register({
1043
+ id: NETCODE_CHANNEL_ID,
1044
+ kind: 'sync',
1045
+ ownership: 'shared',
1046
+ peerModel: 'node-first',
1047
+ routing: 'stream-envelope',
1048
+ readiness: 'settled-peer',
1049
+ description: 'OpenRTC netcode V1 messages and state snapshots.',
1050
+ });
1051
+ }
1052
+ }
1053
+ export function createNetcode(options = {}) {
1054
+ return new OpenRtcNetcodeClient(options);
1055
+ }
1056
+ export function shouldInitiateConnection(localPeerId, remotePeerId) {
1057
+ return localPeerId.localeCompare(remotePeerId) < 0;
1058
+ }
1059
+ function normalizeLobbyId(lobbyId) {
1060
+ return lobbyId.trim().toUpperCase();
1061
+ }
1062
+ function hasActiveMember(member) {
1063
+ const now = Date.now();
1064
+ if (typeof member.expiresAt === 'number') {
1065
+ return member.expiresAt >= now;
1066
+ }
1067
+ const lastSeenAt = member.lastSeenAt ?? member.joinedAt ?? now;
1068
+ return now - lastSeenAt <= 5 * 60 * 1000;
1069
+ }
1070
+ function resolveOwnerId(members, fallback) {
1071
+ const candidates = (members ?? [])
1072
+ .filter((member) => typeof member.nodeId === 'string')
1073
+ .sort((left, right) => {
1074
+ const joinedDelta = (left.joinedAt ?? 0) - (right.joinedAt ?? 0);
1075
+ return joinedDelta || left.nodeId.localeCompare(right.nodeId);
1076
+ });
1077
+ return candidates[0]?.nodeId ?? fallback;
1078
+ }
1079
+ function parseMetadata(value) {
1080
+ if (!value) {
1081
+ return {};
1082
+ }
1083
+ if (typeof value === 'string') {
1084
+ try {
1085
+ const parsed = JSON.parse(value);
1086
+ return isRecord(parsed) ? stringifyRecord(parsed) : {};
1087
+ }
1088
+ catch {
1089
+ return {};
1090
+ }
1091
+ }
1092
+ return stringifyRecord(value);
1093
+ }
1094
+ function stringifyRecord(value) {
1095
+ const result = {};
1096
+ for (const [key, entry] of Object.entries(value)) {
1097
+ if (typeof entry === 'string') {
1098
+ result[key] = entry;
1099
+ }
1100
+ else if (entry !== undefined) {
1101
+ result[key] = JSON.stringify(entry);
1102
+ }
1103
+ }
1104
+ return result;
1105
+ }
1106
+ function clonePeer(peer) {
1107
+ return {
1108
+ ...peer,
1109
+ stats: peer.stats ? clonePeerStats(peer.stats) : undefined,
1110
+ metadata: { ...peer.metadata },
1111
+ };
1112
+ }
1113
+ function clonePeerStats(stats) {
1114
+ return { ...stats };
1115
+ }
1116
+ function cloneLobby(lobby) {
1117
+ return {
1118
+ ...lobby,
1119
+ metadata: { ...lobby.metadata },
1120
+ members: lobby.members.map(clonePeer),
1121
+ };
1122
+ }
1123
+ function concatBytes(left, right) {
1124
+ const combined = new Uint8Array(left.byteLength + right.byteLength);
1125
+ combined.set(left, 0);
1126
+ combined.set(right, left.byteLength);
1127
+ return combined;
1128
+ }
1129
+ function readU32BE(bytes) {
1130
+ return (((bytes[0] ?? 0) * 0x1000000)
1131
+ + (((bytes[1] ?? 0) << 16) >>> 0)
1132
+ + (((bytes[2] ?? 0) << 8) >>> 0)
1133
+ + (bytes[3] ?? 0)) >>> 0;
1134
+ }
1135
+ function assertReliable(options) {
1136
+ if (options.reliability && options.reliability !== 'reliable') {
1137
+ throw new Error('openrtc-netcode V1 supports reliable messages only.');
1138
+ }
1139
+ }
1140
+ function isRoomNotFoundError(error) {
1141
+ const text = error instanceof Error ? error.message : String(error);
1142
+ return /room not found|not found|status["']?\s*:\s*["']?not_found|code["']?\s*:\s*404/i.test(text);
1143
+ }
1144
+ function isRoomAlreadyExistsError(error) {
1145
+ const text = error instanceof Error ? error.message : String(error);
1146
+ return /room already exists|already exists|ALREADY_EXISTS|conflict|code["']?\s*:\s*409/i.test(text);
1147
+ }
1148
+ function isRoomAdmissionTransientError(error) {
1149
+ const text = error instanceof Error ? error.message : String(error);
1150
+ return isRoomNotFoundError(error)
1151
+ || /permission[_ -]?denied|permission denied|forbidden|code["']?\s*:\s*403|status["']?\s*:\s*["']?permission_denied/i.test(text);
1152
+ }
1153
+ function derivePreferredPayloadTransports(transports, transportPriority) {
1154
+ const configuredPriority = (transportPriority ?? [])
1155
+ .filter((transport) => UPGRADED_PAYLOAD_TRANSPORTS.has(transport));
1156
+ if (configuredPriority.length > 0) {
1157
+ return [...new Set(configuredPriority)];
1158
+ }
1159
+ const preferred = [];
1160
+ if (transports?.webrtc) {
1161
+ preferred.push('webrtc-lan', 'webrtc');
1162
+ }
1163
+ if (transports?.moq) {
1164
+ preferred.push('moq');
1165
+ }
1166
+ return preferred;
1167
+ }
1168
+ function delay(ms) {
1169
+ return new Promise((resolve) => setTimeout(resolve, ms));
1170
+ }
1171
+ async function callOptional(target, method) {
1172
+ const candidate = target;
1173
+ const fn = candidate?.[method];
1174
+ if (typeof fn === 'function') {
1175
+ await fn.call(target);
1176
+ }
1177
+ }
1178
+ function queueTask(callback) {
1179
+ if (typeof globalThis.queueMicrotask === 'function') {
1180
+ globalThis.queueMicrotask(callback);
1181
+ return;
1182
+ }
1183
+ void Promise.resolve().then(callback);
1184
+ }