openrtc-netcode 2.3.1 → 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 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, forms a deterministic P2P mesh between lobby members,
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 signaling.
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 uses OpenRTC netcode connections for SDP/ICE signaling and lets the
54
- browser WebRTC stack carry microphone media:
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
@@ -1,3 +1,2 @@
1
1
  import type { NetcodeClient as NetcodeClientInterface, NetcodeOptions } from './types.js';
2
2
  export declare function createNetcode(options?: NetcodeOptions): NetcodeClientInterface;
3
- export declare function shouldInitiate(localPeerId: string, remotePeerId: string): boolean;
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', 'webrtc-lan', 'moq']);
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, shouldInitiate, (peerId, signal) => this.sendEnvelope(peerId, 'voice.signal', { signal }));
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
- if (!this.runtime || !this.localPeerId) {
314
- return;
315
- }
316
- // An OpenRTC 2.0 room capability is the sole owner of its peer
317
- // lifecycle. Calling connectByTicket here races that owner and can
318
- // restart reciprocal application-key agreement on an already-admitted
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
- void this.voiceController.handlePeerConnected(peerId);
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
- if (this.connectionsByPeer.get(peerId) === connection) {
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
- void this.voiceController.handlePeerConnected(peerId);
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
- void this.voiceController.handlePeerConnected(peerId);
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, shouldInitiate, } from './client.js';
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, VoiceSignalBody, } 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, NetcodeVoiceSignal, SendOptions, } from './types.js';
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, shouldInitiate, } from './client.js';
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';
@@ -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' | 'voice.signal' | 'ping' | 'pong';
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
@@ -36,7 +36,6 @@ export function isEnvelopeKind(value) {
36
36
  || value === 'object.remove'
37
37
  || value === 'lobby.meta'
38
38
  || value === 'member.meta'
39
- || value === 'voice.signal'
40
39
  || value === 'ping'
41
40
  || value === 'pong';
42
41
  }
@@ -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
- const connections = () => room.diagnostics.connections();
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 () => undefined,
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
- const seen = new Set();
61
- const publish = (connection) => {
62
- const id = String(connection.id ?? connection.remoteNodeId ?? '');
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: () => connections().filter((connection) => typeof connection.isPayloadReady !== 'function'
86
- || connection.isPayloadReady()),
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, VoiceSignalBody } from './protocol.js';
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
- lifecycle?: boolean;
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
- connectionState: RTCPeerConnectionState;
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
- onUpgradeStateChange?(callback: (state: 'none' | 'upgrading' | 'upgraded' | 'failed') => void): void;
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 | VoiceSignalBody | Record<string, unknown>;
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, NetcodeVoiceSignal } from './types.js';
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 shouldOffer;
6
- private readonly sendSignal;
6
+ private readonly connectionForPeer;
7
7
  private readonly emitter;
8
8
  private readonly sessions;
9
- private readonly pendingSignals;
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[], shouldOffer: (localPeerId: string, remotePeerId: string) => boolean, sendSignal: (peerId: string, signal: NetcodeVoiceSignal) => Promise<void>);
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 flushPendingSignals;
29
- private applySignal;
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, shouldOffer, sendSignal) {
4
+ constructor(localPeerId, peers, connectionForPeer) {
4
5
  this.localPeerId = localPeerId;
5
6
  this.peers = peers;
6
- this.shouldOffer = shouldOffer;
7
- this.sendSignal = sendSignal;
7
+ this.connectionForPeer = connectionForPeer;
8
8
  this.emitter = new TypedEmitter();
9
9
  this.sessions = new Map();
10
- this.pendingSignals = new Map();
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
- connectionState: session.connection.connectionState,
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
- this.localMediaStream = options.mediaStream ?? await this.captureAudio(options);
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', this.localMediaStream);
40
+ this.emitter.emit('local:stream', localStream);
42
41
  const localPeerId = this.localPeerId();
43
42
  if (localPeerId) {
44
- for (const peer of this.peers().filter((candidate) => candidate.connected)) {
45
- void this.startPeerSession(peer.id, localPeerId);
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
- return this.localMediaStream;
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
- for (const session of this.sessions.values()) {
53
- session.connection.close();
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 (!this.active) {
75
- return;
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
- const session = this.sessions.get(peerId);
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 flushPendingCandidates(session) {
154
- if (!session.connection.remoteDescription || !session.pendingCandidates.length) {
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 existing;
166
- }
167
- const connection = this.createPeerConnection();
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
- pendingCandidates: [],
99
+ stopListening: () => undefined,
173
100
  };
174
- const localStream = this.localMediaStream;
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
- return session;
198
- }
199
- createPeerConnection() {
200
- if (this.options.createPeerConnection) {
201
- return this.options.createPeerConnection(this.options.rtcConfig);
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
- if (typeof RTCPeerConnection === 'undefined') {
204
- throw new Error('Voice chat requires RTCPeerConnection support in this environment.');
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
- return new RTCPeerConnection(this.options.rtcConfig);
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.1",
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.3.1"
45
+ "openrtc": "^2.4.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^24.10.1",