openrtc-netcode 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/client.d.ts +0 -1
- package/dist/client.js +30 -63
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/protocol.d.ts +1 -4
- package/dist/protocol.js +0 -1
- package/dist/roomRuntime.js +73 -17
- package/dist/types.d.ts +27 -21
- package/dist/voice.d.ts +7 -12
- package/dist/voice.js +74 -136
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# openrtc-netcode
|
|
2
2
|
|
|
3
3
|
`openrtc-netcode` is a small multiplayer layer on top of OpenRTC. It treats
|
|
4
|
-
OpenRTC rooms as lobbies,
|
|
4
|
+
OpenRTC rooms as lobbies, observes the runtime-owned P2P mesh between lobby members,
|
|
5
5
|
and exposes reliable typed messages, lightweight replicated state, networked
|
|
6
|
-
objects, and browser voice-chat
|
|
6
|
+
objects, and browser voice-chat media.
|
|
7
7
|
|
|
8
8
|
Gameplay payloads are sent over OpenRTC peer data paths only: Iroh
|
|
9
9
|
relay/direct plus WebRTC or MoQ custom Iroh carriers when enabled. Live
|
|
@@ -27,7 +27,6 @@ const rtc = OpenRTC({
|
|
|
27
27
|
const room = await rtc.rooms.join('duel-room', {
|
|
28
28
|
access: 'capability',
|
|
29
29
|
membership: 'ephemeral',
|
|
30
|
-
maxPeers: 4,
|
|
31
30
|
});
|
|
32
31
|
const netcode = createNetcode({ room });
|
|
33
32
|
await netcode.joinLobby('duel-room');
|
|
@@ -50,8 +49,9 @@ await netcode.objects.spawn({
|
|
|
50
49
|
});
|
|
51
50
|
```
|
|
52
51
|
|
|
53
|
-
Voice chat
|
|
54
|
-
|
|
52
|
+
Voice chat publishes microphone tracks through each already-established
|
|
53
|
+
OpenRTC logical connection. Netcode does not create a second peer connection,
|
|
54
|
+
exchange SDP/ICE, or own transport recovery:
|
|
55
55
|
|
|
56
56
|
```ts
|
|
57
57
|
await netcode.voice.start();
|
package/dist/client.d.ts
CHANGED
package/dist/client.js
CHANGED
|
@@ -10,7 +10,7 @@ const NETCODE_CHANNEL_ID = 'openrtc-netcode/v1';
|
|
|
10
10
|
const PING_INTERVAL_MS = 2000;
|
|
11
11
|
const MAX_SEEN_ENVELOPES = 2048;
|
|
12
12
|
const CHANNEL_OPEN_TIMEOUT_MS = 15000;
|
|
13
|
-
const UPGRADED_PAYLOAD_TRANSPORTS = new Set(['webrtc', '
|
|
13
|
+
const UPGRADED_PAYLOAD_TRANSPORTS = new Set(['webrtc', 'moq']);
|
|
14
14
|
const textEncoder = new TextEncoder();
|
|
15
15
|
const textDecoder = new TextDecoder();
|
|
16
16
|
class ClientImpl {
|
|
@@ -38,7 +38,6 @@ class ClientImpl {
|
|
|
38
38
|
this.channelsByPeer = new Map();
|
|
39
39
|
this.pendingChannelsByPeer = new Map();
|
|
40
40
|
this.connectionIds = new Set();
|
|
41
|
-
this.pendingPeerIds = new Set();
|
|
42
41
|
this.directSendWarnings = new Set();
|
|
43
42
|
this.introPeerIds = new Set();
|
|
44
43
|
this.seenEnvelopeIds = new Set();
|
|
@@ -46,7 +45,9 @@ class ClientImpl {
|
|
|
46
45
|
this.latestStateQueues = new Map();
|
|
47
46
|
this.stateStore = new ReplicatedState((options) => this.resolveStateOwner(options), (key, value, owner) => this.broadcastLatestState(`state:${key}`, 'state.patch', { key, value, owner }));
|
|
48
47
|
this.objectStore = new ObjectStore(() => this.localPeerId, (object) => this.broadcastLatestState(`object:${object.id}`, 'object.upsert', { object }), (id) => this.broadcastEnvelope('object.remove', { id }));
|
|
49
|
-
this.voiceController = new NetcodeVoiceController(() => this.localPeerId, () => this.peers,
|
|
48
|
+
this.voiceController = new NetcodeVoiceController(() => this.localPeerId, () => this.peers, (peerId) => this.connectionsByPeer.get(peerId)
|
|
49
|
+
?? this.runtime?.getConnections().find((connection) => connection.remoteNodeId === peerId || connection.deviceId === peerId)
|
|
50
|
+
?? null);
|
|
50
51
|
}
|
|
51
52
|
get peerId() {
|
|
52
53
|
return this.localPeerId;
|
|
@@ -105,7 +106,6 @@ class ClientImpl {
|
|
|
105
106
|
}
|
|
106
107
|
this.connectionsByPeer.clear();
|
|
107
108
|
this.connectionIds.clear();
|
|
108
|
-
this.pendingPeerIds.clear();
|
|
109
109
|
this.directSendWarnings.clear();
|
|
110
110
|
this.introPeerIds.clear();
|
|
111
111
|
this.peerStatsById.clear();
|
|
@@ -127,6 +127,10 @@ class ClientImpl {
|
|
|
127
127
|
await this.leaveLobby();
|
|
128
128
|
const lobbyId = await runtime.createRoom(options.id);
|
|
129
129
|
this.lobbyMetadata = { ...(options.metadata ?? {}) };
|
|
130
|
+
// Creating a lobby does not itself imply that an injected runtime has
|
|
131
|
+
// joined its membership scope. Join through the lifecycle owner before
|
|
132
|
+
// observing the room so it can establish and project peer routes.
|
|
133
|
+
await runtime.joinRoom(lobbyId, { bootstrapPeers: false });
|
|
130
134
|
return this.activateLobby(lobbyId);
|
|
131
135
|
}
|
|
132
136
|
async joinLobby(lobbyId, options = {}) {
|
|
@@ -218,7 +222,6 @@ class ClientImpl {
|
|
|
218
222
|
await this.closeNetcodeChannels();
|
|
219
223
|
this.currentLobby = null;
|
|
220
224
|
this.peersById.clear();
|
|
221
|
-
this.pendingPeerIds.clear();
|
|
222
225
|
this.introPeerIds.clear();
|
|
223
226
|
this.peerStatsById.clear();
|
|
224
227
|
this.seenEnvelopeIds.clear();
|
|
@@ -299,7 +302,6 @@ class ClientImpl {
|
|
|
299
302
|
}
|
|
300
303
|
this.peersById.delete(peerId);
|
|
301
304
|
this.connectionsByPeer.delete(peerId);
|
|
302
|
-
this.pendingPeerIds.delete(peerId);
|
|
303
305
|
this.introPeerIds.delete(peerId);
|
|
304
306
|
this.peerStatsById.delete(peerId);
|
|
305
307
|
this.emitter.emit('peer:left', clonePeer(peer));
|
|
@@ -310,41 +312,12 @@ class ClientImpl {
|
|
|
310
312
|
void this.reconcileMesh();
|
|
311
313
|
}
|
|
312
314
|
async reconcileMesh() {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
// physical connection. Explicit legacy/test runtimes retain the
|
|
320
|
-
// deterministic mesh dial below.
|
|
321
|
-
if (this.runtime.lifecycle) {
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
const maxPeers = this.options.maxPeers ?? DEFAULT_MAX_PEERS;
|
|
325
|
-
for (const peer of this.peersById.values()) {
|
|
326
|
-
if (this.connectionsByPeer.size >= maxPeers) {
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
if (!peer.ticket || !shouldInitiate(this.localPeerId, peer.id)) {
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
if (this.connectionsByPeer.has(peer.id) || this.pendingPeerIds.has(peer.id)) {
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
this.pendingPeerIds.add(peer.id);
|
|
336
|
-
try {
|
|
337
|
-
const connection = await this.runtime.connectByTicket({ ticket: peer.ticket });
|
|
338
|
-
this.attachConnection(connection, peer.id);
|
|
339
|
-
await this.sendPeerIntro(peer.id, true);
|
|
340
|
-
}
|
|
341
|
-
catch (error) {
|
|
342
|
-
console.warn('[openrtc-netcode] peer dial failed; leaving peer disconnected', error);
|
|
343
|
-
}
|
|
344
|
-
finally {
|
|
345
|
-
this.pendingPeerIds.delete(peer.id);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
315
|
+
// The runtime owns room membership, connection establishment,
|
|
316
|
+
// reconnection, and carrier changes. Netcode observes its projected
|
|
317
|
+
// connections through `onConnection` and readiness queries only.
|
|
318
|
+
// Keeping this boundary as an explicit no-op prevents protocol
|
|
319
|
+
// membership updates from becoming an independent dial lifecycle.
|
|
320
|
+
return;
|
|
348
321
|
}
|
|
349
322
|
attachConnection(connection, peerIdHint) {
|
|
350
323
|
const connectionId = connection.id;
|
|
@@ -367,21 +340,22 @@ class ClientImpl {
|
|
|
367
340
|
});
|
|
368
341
|
if (payloadReady) {
|
|
369
342
|
this.emitter.emit('peer:connected', clonePeer(peer));
|
|
370
|
-
|
|
343
|
+
this.startVoicePeer(peerId);
|
|
371
344
|
}
|
|
372
345
|
connection.onMessage((message) => {
|
|
373
346
|
this.handleConnectionMessage(peerId, message);
|
|
374
347
|
});
|
|
375
|
-
connection.onUpgradeStateChange?.(() => {
|
|
376
|
-
this.refreshPeerPayloadState(peerId);
|
|
377
|
-
});
|
|
378
348
|
connection.onDisconnect(() => {
|
|
379
|
-
|
|
349
|
+
const wasCurrentConnection = this.connectionsByPeer.get(peerId) === connection;
|
|
350
|
+
if (wasCurrentConnection) {
|
|
380
351
|
this.connectionsByPeer.delete(peerId);
|
|
381
352
|
}
|
|
382
353
|
if (connectionId) {
|
|
383
354
|
this.connectionIds.delete(connectionId);
|
|
384
355
|
}
|
|
356
|
+
if (!wasCurrentConnection) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
385
359
|
const disconnected = this.upsertPeer(peerId, { connected: false });
|
|
386
360
|
this.updatePeerStats(peerId, {
|
|
387
361
|
connected: false,
|
|
@@ -397,6 +371,14 @@ class ClientImpl {
|
|
|
397
371
|
void this.sendEnvelope(peerId, 'ping', {});
|
|
398
372
|
}
|
|
399
373
|
}
|
|
374
|
+
startVoicePeer(peerId) {
|
|
375
|
+
void this.voiceController.handlePeerConnected(peerId).catch((error) => {
|
|
376
|
+
console.warn('[openrtc-netcode] media setup failed', {
|
|
377
|
+
peerId,
|
|
378
|
+
error: error instanceof Error ? error.message : String(error),
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
}
|
|
400
382
|
async sendPeerIntro(peerId, force = false) {
|
|
401
383
|
if (!this.localPeerId) {
|
|
402
384
|
return;
|
|
@@ -477,9 +459,6 @@ class ClientImpl {
|
|
|
477
459
|
case 'member.meta':
|
|
478
460
|
this.handleMemberMetadata(peerId, envelope.body);
|
|
479
461
|
break;
|
|
480
|
-
case 'voice.signal':
|
|
481
|
-
this.handleVoiceSignal(peerId, envelope.body);
|
|
482
|
-
break;
|
|
483
462
|
case 'ping':
|
|
484
463
|
void this.sendEnvelope(peerId, 'pong', { pingSentAt: envelope.sentAt });
|
|
485
464
|
break;
|
|
@@ -603,14 +582,6 @@ class ClientImpl {
|
|
|
603
582
|
this.upsertPeer(peerId, { metadata: metadata.values });
|
|
604
583
|
this.emitter.emit('member:metadata', { peerId, values: metadata.values });
|
|
605
584
|
}
|
|
606
|
-
handleVoiceSignal(peerId, body) {
|
|
607
|
-
if (!isRecord(body) || !isRecord(body.signal)) {
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
610
|
-
const signal = { signal: body.signal };
|
|
611
|
-
this.emitter.emit('voice:signal', { peerId, ...signal });
|
|
612
|
-
void this.voiceController.handleSignal(peerId, signal.signal);
|
|
613
|
-
}
|
|
614
585
|
handlePong(peerId, body) {
|
|
615
586
|
if (!isRecord(body) || typeof body.pingSentAt !== 'number') {
|
|
616
587
|
return;
|
|
@@ -656,7 +627,7 @@ class ClientImpl {
|
|
|
656
627
|
if (payloadReady && peer && !peer.connected) {
|
|
657
628
|
const connected = this.upsertPeer(peerId, { connected: true });
|
|
658
629
|
this.emitter.emit('peer:connected', clonePeer(connected));
|
|
659
|
-
|
|
630
|
+
this.startVoicePeer(peerId);
|
|
660
631
|
this.queuePeerIntro(peerId, true);
|
|
661
632
|
}
|
|
662
633
|
this.updatePeerStats(peerId, {
|
|
@@ -774,7 +745,6 @@ class ClientImpl {
|
|
|
774
745
|
scope: NETCODE_CHANNEL_ID,
|
|
775
746
|
channelId: NETCODE_CHANNEL_ID,
|
|
776
747
|
timeoutMs: CHANNEL_OPEN_TIMEOUT_MS,
|
|
777
|
-
nativeLabel: true,
|
|
778
748
|
framing: 'u32be',
|
|
779
749
|
}).then((channel) => {
|
|
780
750
|
this.attachNetcodeChannel(peerId, channel);
|
|
@@ -822,7 +792,7 @@ class ClientImpl {
|
|
|
822
792
|
transport: this.resolvePeerTransport(peerId),
|
|
823
793
|
});
|
|
824
794
|
this.emitter.emit('peer:connected', clonePeer(peer));
|
|
825
|
-
|
|
795
|
+
this.startVoicePeer(peerId);
|
|
826
796
|
this.queuePeerIntro(peerId, true);
|
|
827
797
|
}
|
|
828
798
|
registerIncomingNetcodeChannel() {
|
|
@@ -1030,9 +1000,6 @@ class ClientImpl {
|
|
|
1030
1000
|
export function createNetcode(options = {}) {
|
|
1031
1001
|
return new ClientImpl(options);
|
|
1032
1002
|
}
|
|
1033
|
-
export function shouldInitiate(localPeerId, remotePeerId) {
|
|
1034
|
-
return localPeerId.localeCompare(remotePeerId) < 0;
|
|
1035
|
-
}
|
|
1036
1003
|
function normalizeLobbyId(lobbyId) {
|
|
1037
1004
|
return lobbyId.trim().toUpperCase();
|
|
1038
1005
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { createNetcode,
|
|
1
|
+
export { createNetcode, } from './client.js';
|
|
2
2
|
export { runtimeFromRoom } from './roomRuntime.js';
|
|
3
3
|
export { ObjectStore } from './objects.js';
|
|
4
4
|
export { NETCODE_PROTOCOL, PROTOCOL_VERSION, decodeEnvelope, encodeEnvelope, isEnvelopeKind, } from './protocol.js';
|
|
5
|
-
export type { HelloBody, MetadataBody, NetcodeEnvelope, NetcodeEnvelopeKind, ObjectRemoveBody, ObjectSnapshotBody, ObjectUpsertBody, StatePatchBody, StateSnapshotBody, UserMessageBody,
|
|
6
|
-
export type { CreateLobbyOptions, JoinLobbyOptions, NetVar, NetVarOptions, NetcodeClient, NetcodeEventHandler, NetcodeEventName, NetcodeEvents, NetcodeLobby, NetcodeMessage, MessageHandler, NetcodeObjectChange, NetcodeObjectState, NetcodeObjectStore, NetcodeObjectUpsert, NetcodeOptions, NetcodePeer, NetcodePeerStats, NetcodePhysicsState, NetcodeQuaternion, NetcodeReliability, NetcodeRuntime, NetcodeState, NetcodeTransform, NetcodeVector3, NetcodeVoice, NetcodeVoiceEvents, NetcodeVoiceOptions, NetcodeVoicePeer,
|
|
5
|
+
export type { HelloBody, MetadataBody, NetcodeEnvelope, NetcodeEnvelopeKind, ObjectRemoveBody, ObjectSnapshotBody, ObjectUpsertBody, StatePatchBody, StateSnapshotBody, UserMessageBody, } from './protocol.js';
|
|
6
|
+
export type { CreateLobbyOptions, JoinLobbyOptions, NetVar, NetVarOptions, NetcodeClient, NetcodeEventHandler, NetcodeEventName, NetcodeEvents, NetcodeLobby, NetcodeMessage, MessageHandler, NetcodeObjectChange, NetcodeObjectState, NetcodeObjectStore, NetcodeObjectUpsert, NetcodeOptions, NetcodePeer, NetcodePeerStats, NetcodePhysicsState, NetcodeQuaternion, NetcodeReliability, NetcodeRuntime, NetcodeState, NetcodeTransform, NetcodeVector3, NetcodeVoice, NetcodeVoiceEvents, NetcodeVoiceOptions, NetcodeVoicePeer, SendOptions, } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createNetcode,
|
|
1
|
+
export { createNetcode, } from './client.js';
|
|
2
2
|
export { runtimeFromRoom } from './roomRuntime.js';
|
|
3
3
|
export { ObjectStore } from './objects.js';
|
|
4
4
|
export { NETCODE_PROTOCOL, PROTOCOL_VERSION, decodeEnvelope, encodeEnvelope, isEnvelopeKind, } from './protocol.js';
|
package/dist/protocol.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare const NETCODE_PROTOCOL = "openrtc-netcode";
|
|
2
2
|
export declare const PROTOCOL_VERSION = 1;
|
|
3
|
-
export type NetcodeEnvelopeKind = 'hello' | 'peer' | 'user' | 'state.snapshot' | 'state.patch' | 'object.snapshot' | 'object.upsert' | 'object.remove' | 'lobby.meta' | 'member.meta' | '
|
|
3
|
+
export type NetcodeEnvelopeKind = 'hello' | 'peer' | 'user' | 'state.snapshot' | 'state.patch' | 'object.snapshot' | 'object.upsert' | 'object.remove' | 'lobby.meta' | 'member.meta' | 'ping' | 'pong';
|
|
4
4
|
export interface NetcodeEnvelope<TBody = unknown> {
|
|
5
5
|
protocol: typeof NETCODE_PROTOCOL;
|
|
6
6
|
v: typeof PROTOCOL_VERSION;
|
|
@@ -41,9 +41,6 @@ export interface ObjectRemoveBody {
|
|
|
41
41
|
export interface MetadataBody {
|
|
42
42
|
values: Record<string, string>;
|
|
43
43
|
}
|
|
44
|
-
export interface VoiceSignalBody {
|
|
45
|
-
signal: unknown;
|
|
46
|
-
}
|
|
47
44
|
export declare function encodeEnvelope(envelope: NetcodeEnvelope): string;
|
|
48
45
|
export declare function decodeEnvelope(value: unknown): NetcodeEnvelope | null;
|
|
49
46
|
export declare function isEnvelopeKind(value: unknown): value is NetcodeEnvelopeKind;
|
package/dist/protocol.js
CHANGED
package/dist/roomRuntime.js
CHANGED
|
@@ -21,13 +21,77 @@ function peerMember(peer) {
|
|
|
21
21
|
* contract. It never creates a second room, user scope, or base space.
|
|
22
22
|
*/
|
|
23
23
|
export function runtimeFromRoom(room) {
|
|
24
|
-
|
|
24
|
+
// Diagnostics expose raw core connections; only the avenue's public
|
|
25
|
+
// onConnection projection owns the stable media-enabled logical view.
|
|
26
|
+
const adaptedByPeer = new Map();
|
|
27
|
+
const listeners = new Set();
|
|
28
|
+
let stopConnectionObservation = null;
|
|
29
|
+
const rawConnections = () => room.diagnostics.connections();
|
|
30
|
+
const peerIdFor = (connection) => connection.peerId ?? connection.remoteNodeId ?? connection.deviceId ?? connection.id ?? '';
|
|
31
|
+
const rawForPeer = (peerId) => rawConnections().find((connection) => peerIdFor(connection) === peerId);
|
|
32
|
+
const readyPeerIds = (options = {}) => new Set(room.diagnostics.applicationReadyConnections(options).map(peerIdFor));
|
|
33
|
+
const adaptConnection = (connection) => {
|
|
34
|
+
const peerId = peerIdFor(connection);
|
|
35
|
+
let closed = false;
|
|
36
|
+
let adapted;
|
|
37
|
+
const removeFromProjection = () => {
|
|
38
|
+
closed = true;
|
|
39
|
+
if (adaptedByPeer.get(peerId) === adapted)
|
|
40
|
+
adaptedByPeer.delete(peerId);
|
|
41
|
+
};
|
|
42
|
+
adapted = {
|
|
43
|
+
id: connection.id,
|
|
44
|
+
deviceId: peerId,
|
|
45
|
+
remoteNodeId: peerId,
|
|
46
|
+
get isClosed() { return closed; },
|
|
47
|
+
media: connection.media,
|
|
48
|
+
getTransportStatus: () => rawForPeer(peerId)?.getTransportStatus?.() ?? {
|
|
49
|
+
activeTransport: 'unknown',
|
|
50
|
+
parallelTransport: null,
|
|
51
|
+
},
|
|
52
|
+
getAvailableTransports: () => rawForPeer(peerId)?.getAvailableTransports?.() ?? [],
|
|
53
|
+
isPayloadReady: (options = {}) => !closed && readyPeerIds(options).has(peerId),
|
|
54
|
+
send: (message) => connection.send(message),
|
|
55
|
+
onMessage: (callback) => connection.onMessage(callback),
|
|
56
|
+
onDisconnect: (callback) => connection.onClose(() => {
|
|
57
|
+
closed = true;
|
|
58
|
+
callback();
|
|
59
|
+
}),
|
|
60
|
+
};
|
|
61
|
+
connection.onClose(removeFromProjection);
|
|
62
|
+
return adapted;
|
|
63
|
+
};
|
|
64
|
+
const publish = (connection) => {
|
|
65
|
+
const peerId = peerIdFor(connection);
|
|
66
|
+
if (!peerId || adaptedByPeer.has(peerId))
|
|
67
|
+
return;
|
|
68
|
+
const adapted = adaptConnection(connection);
|
|
69
|
+
if (adapted.isClosed)
|
|
70
|
+
return;
|
|
71
|
+
adaptedByPeer.set(peerId, adapted);
|
|
72
|
+
for (const listener of listeners)
|
|
73
|
+
listener(adapted);
|
|
74
|
+
};
|
|
75
|
+
const ensureConnectionObservation = () => {
|
|
76
|
+
if (stopConnectionObservation)
|
|
77
|
+
return;
|
|
78
|
+
stopConnectionObservation = room.onConnection(publish);
|
|
79
|
+
};
|
|
80
|
+
const connections = () => {
|
|
81
|
+
ensureConnectionObservation();
|
|
82
|
+
return [...adaptedByPeer.values()];
|
|
83
|
+
};
|
|
25
84
|
return {
|
|
26
85
|
lifecycle: true,
|
|
27
86
|
channels: room.channels,
|
|
28
87
|
initialize: async () => undefined,
|
|
29
88
|
start: async () => undefined,
|
|
30
|
-
stop: async () =>
|
|
89
|
+
stop: async () => {
|
|
90
|
+
stopConnectionObservation?.();
|
|
91
|
+
stopConnectionObservation = null;
|
|
92
|
+
listeners.clear();
|
|
93
|
+
adaptedByPeer.clear();
|
|
94
|
+
},
|
|
31
95
|
getNodeId: async () => {
|
|
32
96
|
const status = await room.diagnostics.status();
|
|
33
97
|
const nodeId = typeof status.localNodeId === 'string' ? status.localNodeId.trim() : '';
|
|
@@ -57,21 +121,11 @@ export function runtimeFromRoom(room) {
|
|
|
57
121
|
.map((peer) => peerMember(peer));
|
|
58
122
|
},
|
|
59
123
|
onConnection: (callback) => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (!id || seen.has(id))
|
|
64
|
-
return;
|
|
65
|
-
seen.add(id);
|
|
66
|
-
callback(connection);
|
|
67
|
-
};
|
|
68
|
-
connections().forEach(publish);
|
|
69
|
-
return room.diagnostics.onConnection((connection) => {
|
|
70
|
-
publish(connection);
|
|
71
|
-
});
|
|
124
|
+
listeners.add(callback);
|
|
125
|
+
ensureConnectionObservation();
|
|
126
|
+
return () => listeners.delete(callback);
|
|
72
127
|
},
|
|
73
128
|
getConnections: connections,
|
|
74
|
-
connectByTicket: ({ ticket, timeoutMs }) => room.peers.connect({ ticket, timeoutMs }),
|
|
75
129
|
// The room capability already authorized the peer route. A protocol
|
|
76
130
|
// channel is not another admission avenue and must not replace that
|
|
77
131
|
// lifecycle scope with the channel identifier (which would trigger a
|
|
@@ -82,7 +136,9 @@ export function runtimeFromRoom(room) {
|
|
|
82
136
|
existingRouteOnly: true,
|
|
83
137
|
}),
|
|
84
138
|
onChannelStream: (channelId, callback) => room.channels.onIncomingChannel(channelId, callback),
|
|
85
|
-
readyConnections: (
|
|
86
|
-
|
|
139
|
+
readyConnections: (options = {}) => {
|
|
140
|
+
const ready = readyPeerIds(options);
|
|
141
|
+
return connections().filter((connection) => ready.has(connection.remoteNodeId ?? ''));
|
|
142
|
+
},
|
|
87
143
|
};
|
|
88
144
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import type { ReadinessOptions as OpenRtcApplicationPayloadReadinessOptions, IncomingStream, ScopedChannelOptions, ScopedLogicalChannel } from 'openrtc/runtime';
|
|
2
2
|
import type { Room } from 'openrtc';
|
|
3
|
-
import type { MetadataBody, NetcodeEnvelope, ObjectRemoveBody, ObjectSnapshotBody, ObjectUpsertBody, StatePatchBody, StateSnapshotBody, UserMessageBody
|
|
3
|
+
import type { MetadataBody, NetcodeEnvelope, ObjectRemoveBody, ObjectSnapshotBody, ObjectUpsertBody, StatePatchBody, StateSnapshotBody, UserMessageBody } from './protocol.js';
|
|
4
4
|
export interface NetcodeRuntime {
|
|
5
5
|
/**
|
|
6
6
|
* The activated capability owns discovery and physical peer lifecycle.
|
|
7
7
|
* Protocol layers must observe its connections instead of issuing a
|
|
8
8
|
* second ticket dial for the same avenue.
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Marks the adapter as the authoritative owner of room membership and
|
|
12
|
+
* physical peer lifecycle. This is deliberately required: netcode only
|
|
13
|
+
* observes ready routes and must never create a parallel dial/retry loop.
|
|
14
|
+
*/
|
|
15
|
+
readonly lifecycle: true;
|
|
11
16
|
initialize?(): Promise<void> | void;
|
|
12
17
|
start?(): Promise<void> | void;
|
|
13
18
|
stop?(): Promise<void> | void;
|
|
@@ -21,10 +26,6 @@ export interface NetcodeRuntime {
|
|
|
21
26
|
getRoomMembers(roomId: string): Promise<RuntimeMemberLike[]>;
|
|
22
27
|
onConnection(callback: (connection: RuntimeConnectionLike) => void): () => void;
|
|
23
28
|
getConnections(): RuntimeConnectionLike[];
|
|
24
|
-
connectByTicket(target: {
|
|
25
|
-
ticket: string;
|
|
26
|
-
timeoutMs?: number;
|
|
27
|
-
}): Promise<RuntimeConnectionLike>;
|
|
28
29
|
connectScopedChannel(options: ScopedChannelOptions): Promise<ScopedLogicalChannel>;
|
|
29
30
|
onChannelStream(channelId: string, callback: (stream: IncomingStream) => boolean | void): () => void;
|
|
30
31
|
readyConnections(options?: ReadinessOptions): RuntimeConnectionLike[];
|
|
@@ -37,7 +38,6 @@ export interface NetcodeOptions {
|
|
|
37
38
|
room?: Room;
|
|
38
39
|
preferredTransports?: ReadinessOptions['preferredTransports'];
|
|
39
40
|
displayName?: string;
|
|
40
|
-
maxPeers?: number;
|
|
41
41
|
localFallback?: boolean;
|
|
42
42
|
}
|
|
43
43
|
export interface CreateLobbyOptions {
|
|
@@ -147,23 +147,16 @@ export interface NetcodeObjectStore {
|
|
|
147
147
|
remove(id: string): Promise<void>;
|
|
148
148
|
onChange(listener: (change: NetcodeObjectChange) => void): () => void;
|
|
149
149
|
}
|
|
150
|
-
export interface NetcodeVoiceSignal {
|
|
151
|
-
description?: RTCSessionDescriptionInit;
|
|
152
|
-
candidate?: RTCIceCandidateInit;
|
|
153
|
-
}
|
|
154
150
|
export interface NetcodeVoiceOptions {
|
|
155
151
|
mediaStream?: MediaStream;
|
|
156
152
|
mediaDevices?: Pick<MediaDevices, 'getUserMedia'>;
|
|
157
153
|
audio?: boolean | MediaTrackConstraints;
|
|
158
|
-
rtcConfig?: RTCConfiguration;
|
|
159
|
-
createPeerConnection?: (config?: RTCConfiguration) => RTCPeerConnection;
|
|
160
154
|
stopTracksOnStop?: boolean;
|
|
161
155
|
}
|
|
162
156
|
export interface NetcodeVoicePeer {
|
|
163
157
|
peerId: string;
|
|
164
158
|
stream: MediaStream | null;
|
|
165
|
-
|
|
166
|
-
iceConnectionState: RTCIceConnectionState;
|
|
159
|
+
connected: boolean;
|
|
167
160
|
}
|
|
168
161
|
export interface NetcodeVoiceEvents {
|
|
169
162
|
'local:stream': MediaStream;
|
|
@@ -205,9 +198,6 @@ export interface NetcodeEvents {
|
|
|
205
198
|
'member:metadata': MetadataBody & {
|
|
206
199
|
peerId: string;
|
|
207
200
|
};
|
|
208
|
-
'voice:signal': VoiceSignalBody & {
|
|
209
|
-
peerId: string;
|
|
210
|
-
};
|
|
211
201
|
}
|
|
212
202
|
export type NetcodeEventName = keyof NetcodeEvents;
|
|
213
203
|
export type NetcodeEventHandler<K extends NetcodeEventName> = (payload: NetcodeEvents[K]) => void;
|
|
@@ -251,13 +241,29 @@ export type RuntimeConnectionLike = {
|
|
|
251
241
|
};
|
|
252
242
|
getAvailableTransports?(): string[];
|
|
253
243
|
isPayloadReady?(options?: ReadinessOptions): boolean;
|
|
254
|
-
|
|
255
|
-
requestWebRTCUpgrade?(reason?: string): void;
|
|
244
|
+
media: RuntimeMediaConnectionLike;
|
|
256
245
|
send(message: unknown): Promise<void> | void;
|
|
257
246
|
onMessage(callback: (message: unknown) => void): void | (() => void);
|
|
258
247
|
onDisconnect(callback: () => void): void | (() => void);
|
|
259
248
|
disconnect?(): Promise<void> | void;
|
|
260
249
|
};
|
|
250
|
+
export type RuntimeMediaSenderLike = {
|
|
251
|
+
readonly id: string;
|
|
252
|
+
stop(): Promise<void> | void;
|
|
253
|
+
};
|
|
254
|
+
export type RuntimeMediaTrackEventLike = {
|
|
255
|
+
readonly track: MediaStreamTrack;
|
|
256
|
+
readonly streams: readonly MediaStream[];
|
|
257
|
+
};
|
|
258
|
+
export type RuntimeMediaConnectionLike = {
|
|
259
|
+
addTrack(track: MediaStreamTrack, options?: {
|
|
260
|
+
streams?: readonly MediaStream[];
|
|
261
|
+
codec?: string;
|
|
262
|
+
bitrate?: number;
|
|
263
|
+
}): Promise<RuntimeMediaSenderLike>;
|
|
264
|
+
removeTrack(sender: RuntimeMediaSenderLike): Promise<void>;
|
|
265
|
+
onTrack(callback: (event: RuntimeMediaTrackEventLike) => void): () => void;
|
|
266
|
+
};
|
|
261
267
|
export type ReadinessOptions = OpenRtcApplicationPayloadReadinessOptions;
|
|
262
268
|
export type RuntimeMemberLike = {
|
|
263
269
|
nodeId?: string;
|
|
@@ -268,4 +274,4 @@ export type RuntimeMemberLike = {
|
|
|
268
274
|
expiresAt?: number;
|
|
269
275
|
metadata?: string | Record<string, unknown> | null;
|
|
270
276
|
};
|
|
271
|
-
export type RuntimeEnvelopeBody = UserMessageBody | StateSnapshotBody | StatePatchBody | ObjectSnapshotBody | ObjectUpsertBody | ObjectRemoveBody | MetadataBody |
|
|
277
|
+
export type RuntimeEnvelopeBody = UserMessageBody | StateSnapshotBody | StatePatchBody | ObjectSnapshotBody | ObjectUpsertBody | ObjectRemoveBody | MetadataBody | Record<string, unknown>;
|
package/dist/voice.d.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import type { NetcodePeer, NetcodeVoice, NetcodeVoiceEvents, NetcodeVoiceOptions, NetcodeVoicePeer,
|
|
1
|
+
import type { NetcodePeer, NetcodeVoice, NetcodeVoiceEvents, NetcodeVoiceOptions, NetcodeVoicePeer, RuntimeConnectionLike } from './types.js';
|
|
2
|
+
/** Voice is a media facade over the already-established logical peer route. */
|
|
2
3
|
export declare class NetcodeVoiceController implements NetcodeVoice {
|
|
3
4
|
private readonly localPeerId;
|
|
4
5
|
private readonly peers;
|
|
5
|
-
private readonly
|
|
6
|
-
private readonly sendSignal;
|
|
6
|
+
private readonly connectionForPeer;
|
|
7
7
|
private readonly emitter;
|
|
8
8
|
private readonly sessions;
|
|
9
|
-
private readonly
|
|
9
|
+
private readonly closingSessions;
|
|
10
10
|
private localMediaStream;
|
|
11
11
|
private options;
|
|
12
12
|
private active;
|
|
13
13
|
private muted;
|
|
14
|
-
constructor(localPeerId: () => string | null, peers: () => NetcodePeer[],
|
|
14
|
+
constructor(localPeerId: () => string | null, peers: () => NetcodePeer[], connectionForPeer: (peerId: string) => RuntimeConnectionLike | null);
|
|
15
15
|
get enabled(): boolean;
|
|
16
16
|
get localStream(): MediaStream | null;
|
|
17
17
|
get isMuted(): boolean;
|
|
@@ -22,14 +22,9 @@ export declare class NetcodeVoiceController implements NetcodeVoice {
|
|
|
22
22
|
on<K extends keyof NetcodeVoiceEvents>(event: K, handler: (payload: NetcodeVoiceEvents[K]) => void): () => void;
|
|
23
23
|
handlePeerConnected(peerId: string): Promise<void>;
|
|
24
24
|
handlePeerDisconnected(peerId: string): void;
|
|
25
|
-
handleSignal(peerId: string, signal: NetcodeVoiceSignal): Promise<void>;
|
|
26
|
-
private createAndSendOffer;
|
|
27
25
|
private startPeerSession;
|
|
28
|
-
private
|
|
29
|
-
private
|
|
30
|
-
private flushPendingCandidates;
|
|
31
|
-
private ensureSession;
|
|
32
|
-
private createPeerConnection;
|
|
26
|
+
private closePeerSession;
|
|
27
|
+
private trackClosing;
|
|
33
28
|
private captureAudio;
|
|
34
29
|
private applyMuteState;
|
|
35
30
|
}
|
package/dist/voice.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { TypedEmitter } from './events.js';
|
|
2
|
+
/** Voice is a media facade over the already-established logical peer route. */
|
|
2
3
|
export class NetcodeVoiceController {
|
|
3
|
-
constructor(localPeerId, peers,
|
|
4
|
+
constructor(localPeerId, peers, connectionForPeer) {
|
|
4
5
|
this.localPeerId = localPeerId;
|
|
5
6
|
this.peers = peers;
|
|
6
|
-
this.
|
|
7
|
-
this.sendSignal = sendSignal;
|
|
7
|
+
this.connectionForPeer = connectionForPeer;
|
|
8
8
|
this.emitter = new TypedEmitter();
|
|
9
9
|
this.sessions = new Map();
|
|
10
|
-
this.
|
|
10
|
+
this.closingSessions = new Set();
|
|
11
11
|
this.localMediaStream = null;
|
|
12
12
|
this.options = {};
|
|
13
13
|
this.active = false;
|
|
@@ -26,38 +26,42 @@ export class NetcodeVoiceController {
|
|
|
26
26
|
return Array.from(this.sessions.values()).map((session) => ({
|
|
27
27
|
peerId: session.peerId,
|
|
28
28
|
stream: session.remoteStream,
|
|
29
|
-
|
|
30
|
-
iceConnectionState: session.connection.iceConnectionState,
|
|
29
|
+
connected: session.connection.isClosed !== true,
|
|
31
30
|
}));
|
|
32
31
|
}
|
|
33
32
|
async start(options = {}) {
|
|
34
|
-
if (this.active && this.localMediaStream)
|
|
33
|
+
if (this.active && this.localMediaStream)
|
|
35
34
|
return this.localMediaStream;
|
|
36
|
-
}
|
|
37
35
|
this.options = options;
|
|
38
|
-
|
|
36
|
+
const localStream = options.mediaStream ?? await this.captureAudio(options);
|
|
37
|
+
this.localMediaStream = localStream;
|
|
39
38
|
this.active = true;
|
|
40
39
|
this.applyMuteState();
|
|
41
|
-
this.emitter.emit('local:stream',
|
|
40
|
+
this.emitter.emit('local:stream', localStream);
|
|
42
41
|
const localPeerId = this.localPeerId();
|
|
43
42
|
if (localPeerId) {
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
try {
|
|
44
|
+
await Promise.all(this.peers()
|
|
45
|
+
.filter((peer) => peer.connected)
|
|
46
|
+
.map((peer) => this.startPeerSession(peer.id)));
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
await this.stop();
|
|
50
|
+
throw error;
|
|
46
51
|
}
|
|
47
52
|
}
|
|
48
|
-
|
|
53
|
+
if (!this.active || this.localMediaStream !== localStream) {
|
|
54
|
+
throw new Error('Voice start was superseded by stop.');
|
|
55
|
+
}
|
|
56
|
+
return localStream;
|
|
49
57
|
}
|
|
50
58
|
async stop() {
|
|
51
59
|
this.active = false;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
this.sessions.clear();
|
|
56
|
-
this.pendingSignals.clear();
|
|
60
|
+
const current = [...this.sessions.keys()].map((peerId) => this.closePeerSession(peerId));
|
|
61
|
+
await Promise.allSettled([...current, ...this.closingSessions]);
|
|
57
62
|
if (this.options.stopTracksOnStop !== false) {
|
|
58
|
-
for (const track of this.localMediaStream?.getTracks() ?? [])
|
|
63
|
+
for (const track of this.localMediaStream?.getTracks() ?? [])
|
|
59
64
|
track.stop();
|
|
60
|
-
}
|
|
61
65
|
}
|
|
62
66
|
this.localMediaStream = null;
|
|
63
67
|
this.emitter.emit('peers', []);
|
|
@@ -71,139 +75,74 @@ export class NetcodeVoiceController {
|
|
|
71
75
|
return this.emitter.on(event, handler);
|
|
72
76
|
}
|
|
73
77
|
async handlePeerConnected(peerId) {
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
const localPeerId = this.localPeerId();
|
|
78
|
-
if (!localPeerId) {
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
await this.startPeerSession(peerId, localPeerId);
|
|
78
|
+
if (this.active)
|
|
79
|
+
await this.startPeerSession(peerId);
|
|
82
80
|
}
|
|
83
81
|
handlePeerDisconnected(peerId) {
|
|
84
|
-
|
|
85
|
-
if (!session) {
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
session.connection.close();
|
|
89
|
-
this.sessions.delete(peerId);
|
|
90
|
-
this.emitter.emit('peer:left', { peerId });
|
|
91
|
-
this.emitter.emit('peers', this.listPeers());
|
|
92
|
-
}
|
|
93
|
-
async handleSignal(peerId, signal) {
|
|
94
|
-
if (!this.active) {
|
|
95
|
-
const pending = this.pendingSignals.get(peerId) ?? [];
|
|
96
|
-
pending.push(signal);
|
|
97
|
-
this.pendingSignals.set(peerId, pending);
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
const session = this.ensureSession(peerId);
|
|
101
|
-
await this.applySignal(session, signal);
|
|
102
|
-
}
|
|
103
|
-
async createAndSendOffer(session) {
|
|
104
|
-
const offer = await session.connection.createOffer();
|
|
105
|
-
await session.connection.setLocalDescription(offer);
|
|
106
|
-
if (session.connection.localDescription) {
|
|
107
|
-
await this.sendSignal(session.peerId, { description: session.connection.localDescription.toJSON() });
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
async startPeerSession(peerId, localPeerId) {
|
|
111
|
-
try {
|
|
112
|
-
const session = this.ensureSession(peerId);
|
|
113
|
-
await this.flushPendingSignals(peerId);
|
|
114
|
-
if (this.shouldOffer(localPeerId, peerId)) {
|
|
115
|
-
await this.createAndSendOffer(session);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
catch (error) {
|
|
119
|
-
console.warn('[openrtc-netcode] voice peer setup failed', error);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
async flushPendingSignals(peerId) {
|
|
123
|
-
const pending = this.pendingSignals.get(peerId);
|
|
124
|
-
if (!pending?.length) {
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
this.pendingSignals.delete(peerId);
|
|
128
|
-
const session = this.ensureSession(peerId);
|
|
129
|
-
for (const signal of pending) {
|
|
130
|
-
await this.applySignal(session, signal);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
async applySignal(session, signal) {
|
|
134
|
-
if (signal.description) {
|
|
135
|
-
await session.connection.setRemoteDescription(signal.description);
|
|
136
|
-
await this.flushPendingCandidates(session);
|
|
137
|
-
if (signal.description.type === 'offer') {
|
|
138
|
-
const answer = await session.connection.createAnswer();
|
|
139
|
-
await session.connection.setLocalDescription(answer);
|
|
140
|
-
if (session.connection.localDescription) {
|
|
141
|
-
await this.sendSignal(session.peerId, { description: session.connection.localDescription.toJSON() });
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
if (signal.candidate) {
|
|
146
|
-
if (!session.connection.remoteDescription) {
|
|
147
|
-
session.pendingCandidates.push(signal.candidate);
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
await session.connection.addIceCandidate(signal.candidate);
|
|
151
|
-
}
|
|
82
|
+
this.trackClosing(this.closePeerSession(peerId));
|
|
152
83
|
}
|
|
153
|
-
async
|
|
154
|
-
|
|
84
|
+
async startPeerSession(peerId) {
|
|
85
|
+
const connection = this.connectionForPeer(peerId);
|
|
86
|
+
const localStream = this.localMediaStream;
|
|
87
|
+
if (!connection || !localStream)
|
|
155
88
|
return;
|
|
156
|
-
}
|
|
157
|
-
const pending = session.pendingCandidates.splice(0);
|
|
158
|
-
for (const candidate of pending) {
|
|
159
|
-
await session.connection.addIceCandidate(candidate);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
ensureSession(peerId) {
|
|
163
89
|
const existing = this.sessions.get(peerId);
|
|
164
|
-
if (existing)
|
|
165
|
-
return
|
|
166
|
-
|
|
167
|
-
|
|
90
|
+
if (existing?.connection === connection)
|
|
91
|
+
return;
|
|
92
|
+
if (existing)
|
|
93
|
+
await this.closePeerSession(peerId, false);
|
|
168
94
|
const session = {
|
|
169
95
|
peerId,
|
|
170
96
|
connection,
|
|
97
|
+
senders: [],
|
|
171
98
|
remoteStream: null,
|
|
172
|
-
|
|
99
|
+
stopListening: () => undefined,
|
|
173
100
|
};
|
|
174
|
-
|
|
175
|
-
if (localStream) {
|
|
176
|
-
for (const track of localStream.getAudioTracks()) {
|
|
177
|
-
connection.addTrack(track, localStream);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
connection.onicecandidate = (event) => {
|
|
181
|
-
if (event.candidate) {
|
|
182
|
-
void this.sendSignal(peerId, { candidate: event.candidate.toJSON() });
|
|
183
|
-
}
|
|
184
|
-
};
|
|
185
|
-
connection.ontrack = (event) => {
|
|
101
|
+
session.stopListening = connection.media.onTrack((event) => {
|
|
186
102
|
session.remoteStream = event.streams[0] ?? null;
|
|
187
103
|
if (session.remoteStream) {
|
|
188
104
|
this.emitter.emit('peer:stream', { peerId, stream: session.remoteStream });
|
|
189
105
|
}
|
|
190
106
|
this.emitter.emit('peers', this.listPeers());
|
|
191
|
-
};
|
|
192
|
-
connection.onconnectionstatechange = () => {
|
|
193
|
-
this.emitter.emit('peers', this.listPeers());
|
|
194
|
-
};
|
|
107
|
+
});
|
|
195
108
|
this.sessions.set(peerId, session);
|
|
196
109
|
this.emitter.emit('peers', this.listPeers());
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
110
|
+
try {
|
|
111
|
+
await Promise.resolve();
|
|
112
|
+
for (const track of localStream.getAudioTracks()) {
|
|
113
|
+
const sender = await connection.media.addTrack(track, { streams: [localStream] });
|
|
114
|
+
if (this.sessions.get(peerId) !== session || !this.active || connection.isClosed === true) {
|
|
115
|
+
await connection.media.removeTrack(sender);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
session.senders.push(sender);
|
|
119
|
+
}
|
|
202
120
|
}
|
|
203
|
-
|
|
204
|
-
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (this.sessions.get(peerId) === session) {
|
|
123
|
+
await this.closePeerSession(peerId, false);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
await Promise.allSettled(session.senders.map((sender) => connection.media.removeTrack(sender)));
|
|
127
|
+
}
|
|
128
|
+
throw error;
|
|
205
129
|
}
|
|
206
|
-
|
|
130
|
+
}
|
|
131
|
+
async closePeerSession(peerId, emit = true) {
|
|
132
|
+
const session = this.sessions.get(peerId);
|
|
133
|
+
if (!session)
|
|
134
|
+
return;
|
|
135
|
+
this.sessions.delete(peerId);
|
|
136
|
+
session.stopListening();
|
|
137
|
+
await Promise.allSettled(session.senders.map((sender) => session.connection.media.removeTrack(sender)));
|
|
138
|
+
if (emit) {
|
|
139
|
+
this.emitter.emit('peer:left', { peerId });
|
|
140
|
+
this.emitter.emit('peers', this.listPeers());
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
trackClosing(operation) {
|
|
144
|
+
this.closingSessions.add(operation);
|
|
145
|
+
void operation.then(() => this.closingSessions.delete(operation), () => this.closingSessions.delete(operation));
|
|
207
146
|
}
|
|
208
147
|
async captureAudio(options) {
|
|
209
148
|
const mediaDevices = options.mediaDevices ?? globalThis.navigator?.mediaDevices;
|
|
@@ -220,8 +159,7 @@ export class NetcodeVoiceController {
|
|
|
220
159
|
});
|
|
221
160
|
}
|
|
222
161
|
applyMuteState() {
|
|
223
|
-
for (const track of this.localMediaStream?.getAudioTracks() ?? [])
|
|
162
|
+
for (const track of this.localMediaStream?.getAudioTracks() ?? [])
|
|
224
163
|
track.enabled = !this.muted;
|
|
225
|
-
}
|
|
226
164
|
}
|
|
227
165
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openrtc-netcode",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"test:watch": "vitest"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"openrtc": "^2.
|
|
45
|
+
"openrtc": "^2.4.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^24.10.1",
|