openrtc-netcode 0.1.0 → 2.0.0-rc.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 +21 -6
- package/dist/client.d.ts +3 -3
- package/dist/client.js +47 -85
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/latestState.d.ts +9 -0
- package/dist/latestState.js +44 -0
- package/dist/roomRuntime.d.ts +7 -0
- package/dist/roomRuntime.js +88 -0
- package/dist/types.d.ts +36 -23
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,16 +6,31 @@ and exposes reliable typed messages, lightweight replicated state, networked
|
|
|
6
6
|
objects, and browser voice-chat signaling.
|
|
7
7
|
|
|
8
8
|
Gameplay payloads are sent over OpenRTC peer data paths only: Iroh
|
|
9
|
-
relay/direct, WebRTC DataChannel upgrades, and MoQ routes when enabled.
|
|
10
|
-
and
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
relay/direct, WebRTC DataChannel upgrades, and MoQ routes when enabled. Live
|
|
10
|
+
membership and signaling use the OpenRTC coordination gateway; neither core
|
|
11
|
+
netcode nor its payload path uses Firebase, Firestore, or RTDB.
|
|
12
|
+
|
|
13
|
+
`preferredPayloadTransports` filters OpenRTC's typed application-readiness
|
|
14
|
+
query; it does not request promotion. OpenRTC owns optional transport promotion
|
|
15
|
+
and recovery, while netcode only observes route events. Netcode consumes one
|
|
16
|
+
already-activated room handle, so it never retains an unused user or space
|
|
17
|
+
avenue.
|
|
13
18
|
|
|
14
19
|
```ts
|
|
20
|
+
import { OpenRTC } from 'openrtc';
|
|
15
21
|
import { createNetcode } from 'openrtc-netcode';
|
|
16
22
|
|
|
17
|
-
const
|
|
18
|
-
|
|
23
|
+
const rtc = OpenRTC({
|
|
24
|
+
apiKey: 'pk_live_...',
|
|
25
|
+
transports: { webrtc: true },
|
|
26
|
+
});
|
|
27
|
+
const room = await rtc.rooms.join('duel-room', {
|
|
28
|
+
access: 'capability',
|
|
29
|
+
membership: 'ephemeral',
|
|
30
|
+
maxPeers: 4,
|
|
31
|
+
});
|
|
32
|
+
const netcode = createNetcode({ room });
|
|
33
|
+
await netcode.joinLobby('duel-room');
|
|
19
34
|
|
|
20
35
|
netcode.onMessage('chat', (message) => {
|
|
21
36
|
console.log(message.from, message.payload);
|
package/dist/client.d.ts
CHANGED
|
@@ -5,7 +5,6 @@ import type { CreateLobbyOptions, JoinLobbyOptions, NetcodeClient as NetcodeClie
|
|
|
5
5
|
export declare class OpenRtcNetcodeClient implements NetcodeClientInterface {
|
|
6
6
|
private readonly options;
|
|
7
7
|
private runtime;
|
|
8
|
-
private ownsRuntime;
|
|
9
8
|
private localPeerId;
|
|
10
9
|
private currentLobby;
|
|
11
10
|
private currentRoomId;
|
|
@@ -29,10 +28,10 @@ export declare class OpenRtcNetcodeClient implements NetcodeClientInterface {
|
|
|
29
28
|
private readonly connectionIds;
|
|
30
29
|
private readonly pendingPeerIds;
|
|
31
30
|
private readonly directSendWarnings;
|
|
32
|
-
private readonly requestedWebRtcUpgradeConnectionIds;
|
|
33
31
|
private readonly introPeerIds;
|
|
34
32
|
private readonly seenEnvelopeIds;
|
|
35
33
|
private readonly seenEnvelopeOrder;
|
|
34
|
+
private readonly latestStateQueues;
|
|
36
35
|
private readonly stateStore;
|
|
37
36
|
private readonly objectStore;
|
|
38
37
|
private readonly voiceController;
|
|
@@ -81,8 +80,10 @@ export declare class OpenRtcNetcodeClient implements NetcodeClientInterface {
|
|
|
81
80
|
private sendObjectSnapshot;
|
|
82
81
|
private broadcastMemberMetadata;
|
|
83
82
|
private startPeerPingLoop;
|
|
83
|
+
private refreshPeerPayloadState;
|
|
84
84
|
private stopPeerPingLoop;
|
|
85
85
|
private broadcastEnvelope;
|
|
86
|
+
private broadcastLatestState;
|
|
86
87
|
private sendEnvelope;
|
|
87
88
|
private sendDirectEnvelope;
|
|
88
89
|
private warnDirectSendFailure;
|
|
@@ -101,7 +102,6 @@ export declare class OpenRtcNetcodeClient implements NetcodeClientInterface {
|
|
|
101
102
|
private resolvePeerTransport;
|
|
102
103
|
private isPeerPayloadReady;
|
|
103
104
|
private resolvePayloadConnection;
|
|
104
|
-
private requestPreferredTransportUpgrade;
|
|
105
105
|
private payloadReadinessOptions;
|
|
106
106
|
private refreshLobby;
|
|
107
107
|
private requireRuntime;
|
package/dist/client.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { OpenRTC } from 'openrtc';
|
|
2
1
|
import { TypedEmitter } from './events.js';
|
|
3
2
|
import { decodeEnvelope, encodeEnvelope, isRecord, NETCODE_PROTOCOL, NETCODE_PROTOCOL_VERSION, } from './protocol.js';
|
|
4
3
|
import { isNetcodeObjectState, ReplicatedObjectStore } from './objects.js';
|
|
5
4
|
import { ReplicatedState } from './state.js';
|
|
6
5
|
import { NetcodeVoiceController } from './voice.js';
|
|
6
|
+
import { runtimeFromRoom } from './roomRuntime.js';
|
|
7
|
+
import { LatestStateQueue } from './latestState.js';
|
|
7
8
|
const DEFAULT_MAX_PEERS = 8;
|
|
8
9
|
const NETCODE_CHANNEL_ID = 'openrtc-netcode/v1';
|
|
9
10
|
const PING_INTERVAL_MS = 2000;
|
|
@@ -16,7 +17,6 @@ export class OpenRtcNetcodeClient {
|
|
|
16
17
|
constructor(options = {}) {
|
|
17
18
|
this.options = options;
|
|
18
19
|
this.runtime = null;
|
|
19
|
-
this.ownsRuntime = false;
|
|
20
20
|
this.localPeerId = null;
|
|
21
21
|
this.currentLobby = null;
|
|
22
22
|
this.currentRoomId = null;
|
|
@@ -40,12 +40,12 @@ export class OpenRtcNetcodeClient {
|
|
|
40
40
|
this.connectionIds = new Set();
|
|
41
41
|
this.pendingPeerIds = new Set();
|
|
42
42
|
this.directSendWarnings = new Set();
|
|
43
|
-
this.requestedWebRtcUpgradeConnectionIds = new Set();
|
|
44
43
|
this.introPeerIds = new Set();
|
|
45
44
|
this.seenEnvelopeIds = new Set();
|
|
46
45
|
this.seenEnvelopeOrder = [];
|
|
47
|
-
this.
|
|
48
|
-
this.
|
|
46
|
+
this.latestStateQueues = new Map();
|
|
47
|
+
this.stateStore = new ReplicatedState((options) => this.resolveStateOwner(options), (key, value, owner) => this.broadcastLatestState(`state:${key}`, 'state.patch', { key, value, owner }));
|
|
48
|
+
this.objectStore = new ReplicatedObjectStore(() => this.localPeerId, (object) => this.broadcastLatestState(`object:${object.id}`, 'object.upsert', { object }), (id) => this.broadcastEnvelope('object.remove', { id }));
|
|
49
49
|
this.voiceController = new NetcodeVoiceController(() => this.localPeerId, () => this.peers, shouldInitiateConnection, (peerId, signal) => this.sendEnvelope(peerId, 'voice.signal', { signal }));
|
|
50
50
|
}
|
|
51
51
|
get peerId() {
|
|
@@ -75,23 +75,10 @@ export class OpenRtcNetcodeClient {
|
|
|
75
75
|
}
|
|
76
76
|
this.started = true;
|
|
77
77
|
this.stopped = false;
|
|
78
|
-
this.runtime
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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;
|
|
78
|
+
if (!this.options.runtime && !this.options.room) {
|
|
79
|
+
throw new Error('openrtc-netcode requires an activated OpenRTC 2.0 room adapter or an explicit openrtc/runtime client.');
|
|
80
|
+
}
|
|
81
|
+
this.runtime = this.options.runtime ?? runtimeFromRoom(this.options.room);
|
|
95
82
|
await callOptional(this.runtime, 'initialize');
|
|
96
83
|
await callOptional(this.runtime, 'start');
|
|
97
84
|
this.localPeerId = await this.runtime.getNodeId();
|
|
@@ -120,20 +107,17 @@ export class OpenRtcNetcodeClient {
|
|
|
120
107
|
this.connectionIds.clear();
|
|
121
108
|
this.pendingPeerIds.clear();
|
|
122
109
|
this.directSendWarnings.clear();
|
|
123
|
-
this.requestedWebRtcUpgradeConnectionIds.clear();
|
|
124
110
|
this.introPeerIds.clear();
|
|
125
111
|
this.peerStatsById.clear();
|
|
126
112
|
this.seenEnvelopeIds.clear();
|
|
127
113
|
this.seenEnvelopeOrder.length = 0;
|
|
114
|
+
this.latestStateQueues.clear();
|
|
128
115
|
this.stopPeerPingLoop();
|
|
129
116
|
this.peersById.clear();
|
|
130
117
|
this.objectStore.clear();
|
|
131
118
|
await this.voiceController.stop();
|
|
132
119
|
this.emitter.clear();
|
|
133
120
|
this.messageListeners.clear();
|
|
134
|
-
if (this.ownsRuntime) {
|
|
135
|
-
await callOptional(this.runtime, 'stop');
|
|
136
|
-
}
|
|
137
121
|
this.runtime = null;
|
|
138
122
|
this.started = false;
|
|
139
123
|
}
|
|
@@ -239,6 +223,7 @@ export class OpenRtcNetcodeClient {
|
|
|
239
223
|
this.peerStatsById.clear();
|
|
240
224
|
this.seenEnvelopeIds.clear();
|
|
241
225
|
this.seenEnvelopeOrder.length = 0;
|
|
226
|
+
this.latestStateQueues.clear();
|
|
242
227
|
this.objectStore.clear();
|
|
243
228
|
await this.voiceController.stop();
|
|
244
229
|
if (runtime && roomId) {
|
|
@@ -312,13 +297,9 @@ export class OpenRtcNetcodeClient {
|
|
|
312
297
|
if (this.fallbackChannel) {
|
|
313
298
|
continue;
|
|
314
299
|
}
|
|
315
|
-
const connection = this.connectionsByPeer.get(peerId);
|
|
316
300
|
this.peersById.delete(peerId);
|
|
317
301
|
this.connectionsByPeer.delete(peerId);
|
|
318
302
|
this.pendingPeerIds.delete(peerId);
|
|
319
|
-
if (connection?.id) {
|
|
320
|
-
this.requestedWebRtcUpgradeConnectionIds.delete(connection.id);
|
|
321
|
-
}
|
|
322
303
|
this.introPeerIds.delete(peerId);
|
|
323
304
|
this.peerStatsById.delete(peerId);
|
|
324
305
|
this.emitter.emit('peer:left', clonePeer(peer));
|
|
@@ -332,6 +313,14 @@ export class OpenRtcNetcodeClient {
|
|
|
332
313
|
if (!this.runtime || !this.localPeerId) {
|
|
333
314
|
return;
|
|
334
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.managedConnectionLifecycle) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
335
324
|
const maxPeers = this.options.maxPeers ?? DEFAULT_MAX_PEERS;
|
|
336
325
|
for (const peer of this.peersById.values()) {
|
|
337
326
|
if (this.connectionsByPeer.size >= maxPeers) {
|
|
@@ -370,7 +359,6 @@ export class OpenRtcNetcodeClient {
|
|
|
370
359
|
this.connectionIds.add(connectionId);
|
|
371
360
|
}
|
|
372
361
|
this.connectionsByPeer.set(peerId, connection);
|
|
373
|
-
this.requestPreferredTransportUpgrade(connection);
|
|
374
362
|
const payloadReady = this.isPeerPayloadReady(peerId);
|
|
375
363
|
const peer = this.upsertPeer(peerId, { connected: payloadReady });
|
|
376
364
|
this.updatePeerStats(peerId, {
|
|
@@ -384,13 +372,15 @@ export class OpenRtcNetcodeClient {
|
|
|
384
372
|
connection.onMessage((message) => {
|
|
385
373
|
this.handleConnectionMessage(peerId, message);
|
|
386
374
|
});
|
|
375
|
+
connection.onUpgradeStateChange?.(() => {
|
|
376
|
+
this.refreshPeerPayloadState(peerId);
|
|
377
|
+
});
|
|
387
378
|
connection.onDisconnect(() => {
|
|
388
379
|
if (this.connectionsByPeer.get(peerId) === connection) {
|
|
389
380
|
this.connectionsByPeer.delete(peerId);
|
|
390
381
|
}
|
|
391
382
|
if (connectionId) {
|
|
392
383
|
this.connectionIds.delete(connectionId);
|
|
393
|
-
this.requestedWebRtcUpgradeConnectionIds.delete(connectionId);
|
|
394
384
|
}
|
|
395
385
|
const disconnected = this.upsertPeer(peerId, { connected: false });
|
|
396
386
|
this.updatePeerStats(peerId, {
|
|
@@ -653,29 +643,28 @@ export class OpenRtcNetcodeClient {
|
|
|
653
643
|
return;
|
|
654
644
|
}
|
|
655
645
|
for (const peer of this.peersById.values()) {
|
|
656
|
-
const payloadReady = this.
|
|
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
|
-
}
|
|
646
|
+
const payloadReady = this.refreshPeerPayloadState(peer.id);
|
|
669
647
|
if (payloadReady) {
|
|
670
648
|
void this.sendEnvelope(peer.id, 'ping', {});
|
|
671
649
|
}
|
|
672
|
-
this.updatePeerStats(peer.id, {
|
|
673
|
-
connected: payloadReady,
|
|
674
|
-
transport: this.resolvePeerTransport(peer.id),
|
|
675
|
-
});
|
|
676
650
|
}
|
|
677
651
|
}, PING_INTERVAL_MS);
|
|
678
652
|
}
|
|
653
|
+
refreshPeerPayloadState(peerId) {
|
|
654
|
+
const payloadReady = this.isPeerPayloadReady(peerId);
|
|
655
|
+
const peer = this.peersById.get(peerId);
|
|
656
|
+
if (payloadReady && peer && !peer.connected) {
|
|
657
|
+
const connected = this.upsertPeer(peerId, { connected: true });
|
|
658
|
+
this.emitter.emit('peer:connected', clonePeer(connected));
|
|
659
|
+
void this.voiceController.handlePeerConnected(peerId);
|
|
660
|
+
this.queuePeerIntro(peerId, true);
|
|
661
|
+
}
|
|
662
|
+
this.updatePeerStats(peerId, {
|
|
663
|
+
connected: payloadReady,
|
|
664
|
+
transport: this.resolvePeerTransport(peerId),
|
|
665
|
+
});
|
|
666
|
+
return payloadReady;
|
|
667
|
+
}
|
|
679
668
|
stopPeerPingLoop() {
|
|
680
669
|
if (this.peerPingTimer) {
|
|
681
670
|
clearInterval(this.peerPingTimer);
|
|
@@ -690,6 +679,14 @@ export class OpenRtcNetcodeClient {
|
|
|
690
679
|
const sends = Array.from(this.connectionsByPeer.keys()).map((peerId) => this.sendDirectEnvelope(peerId, envelope));
|
|
691
680
|
await Promise.all(sends);
|
|
692
681
|
}
|
|
682
|
+
broadcastLatestState(key, kind, body) {
|
|
683
|
+
let queue = this.latestStateQueues.get(key);
|
|
684
|
+
if (!queue) {
|
|
685
|
+
queue = new LatestStateQueue((state) => this.broadcastEnvelope(state.kind, state.body));
|
|
686
|
+
this.latestStateQueues.set(key, queue);
|
|
687
|
+
}
|
|
688
|
+
return queue.push({ kind, body });
|
|
689
|
+
}
|
|
693
690
|
async sendEnvelope(peerId, kind, body) {
|
|
694
691
|
const envelope = this.createEnvelope(kind, body, peerId);
|
|
695
692
|
if (this.fallbackChannel) {
|
|
@@ -974,28 +971,8 @@ export class OpenRtcNetcodeClient {
|
|
|
974
971
|
}
|
|
975
972
|
return this.connectionsByPeer.get(peerId) ?? null;
|
|
976
973
|
}
|
|
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
974
|
payloadReadinessOptions() {
|
|
997
|
-
const preferredTransports = this.options.preferredPayloadTransports
|
|
998
|
-
?? derivePreferredPayloadTransports(this.options.transports, this.options.transportPriority);
|
|
975
|
+
const preferredTransports = this.options.preferredPayloadTransports ?? [];
|
|
999
976
|
if (preferredTransports.length === 0) {
|
|
1000
977
|
return {};
|
|
1001
978
|
}
|
|
@@ -1150,21 +1127,6 @@ function isRoomAdmissionTransientError(error) {
|
|
|
1150
1127
|
return isRoomNotFoundError(error)
|
|
1151
1128
|
|| /permission[_ -]?denied|permission denied|forbidden|code["']?\s*:\s*403|status["']?\s*:\s*["']?permission_denied/i.test(text);
|
|
1152
1129
|
}
|
|
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
1130
|
function delay(ms) {
|
|
1169
1131
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1170
1132
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { OpenRtcNetcodeClient, createNetcode, shouldInitiateConnection, } from './client.js';
|
|
2
|
+
export { runtimeFromRoom } from './roomRuntime.js';
|
|
2
3
|
export { ReplicatedObjectStore } from './objects.js';
|
|
3
4
|
export { NETCODE_PROTOCOL, NETCODE_PROTOCOL_VERSION, decodeEnvelope, encodeEnvelope, isEnvelopeKind, } from './protocol.js';
|
|
4
5
|
export type { HelloBody, MetadataBody, NetcodeEnvelope, NetcodeEnvelopeKind, ObjectRemoveBody, ObjectSnapshotBody, ObjectUpsertBody, StatePatchBody, StateSnapshotBody, UserMessageBody, VoiceSignalBody, } from './protocol.js';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { OpenRtcNetcodeClient, createNetcode, shouldInitiateConnection, } from './client.js';
|
|
2
|
+
export { runtimeFromRoom } from './roomRuntime.js';
|
|
2
3
|
export { ReplicatedObjectStore } from './objects.js';
|
|
3
4
|
export { NETCODE_PROTOCOL, NETCODE_PROTOCOL_VERSION, decodeEnvelope, encodeEnvelope, isEnvelopeKind, } from './protocol.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Coalesces only queued values; the in-flight state is allowed to finish. */
|
|
2
|
+
export declare class LatestStateQueue<T> {
|
|
3
|
+
private readonly transmit;
|
|
4
|
+
private pending;
|
|
5
|
+
private running;
|
|
6
|
+
constructor(transmit: (value: T) => Promise<void>);
|
|
7
|
+
push(value: T): Promise<void>;
|
|
8
|
+
private pump;
|
|
9
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Coalesces only queued values; the in-flight state is allowed to finish. */
|
|
2
|
+
export class LatestStateQueue {
|
|
3
|
+
constructor(transmit) {
|
|
4
|
+
this.transmit = transmit;
|
|
5
|
+
this.pending = null;
|
|
6
|
+
this.running = false;
|
|
7
|
+
}
|
|
8
|
+
push(value) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
if (this.pending) {
|
|
11
|
+
this.pending.value = value;
|
|
12
|
+
this.pending.waiters.push({ resolve, reject });
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
this.pending = { value, waiters: [{ resolve, reject }] };
|
|
16
|
+
}
|
|
17
|
+
this.pump();
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
pump() {
|
|
21
|
+
if (this.running)
|
|
22
|
+
return;
|
|
23
|
+
this.running = true;
|
|
24
|
+
void (async () => {
|
|
25
|
+
while (this.pending) {
|
|
26
|
+
const current = this.pending;
|
|
27
|
+
this.pending = null;
|
|
28
|
+
try {
|
|
29
|
+
await this.transmit(current.value);
|
|
30
|
+
for (const waiter of current.waiters)
|
|
31
|
+
waiter.resolve();
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
for (const waiter of current.waiters)
|
|
35
|
+
waiter.reject(error);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
})().finally(() => {
|
|
39
|
+
this.running = false;
|
|
40
|
+
if (this.pending)
|
|
41
|
+
this.pump();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { OpenRTCRoomHandle } from 'openrtc';
|
|
2
|
+
import type { NetcodeRuntime } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Adapts one OpenRTC 2.0 capability handle to netcode's transport-only runtime
|
|
5
|
+
* contract. It never creates a second room, user scope, or base space.
|
|
6
|
+
*/
|
|
7
|
+
export declare function runtimeFromRoom(room: OpenRTCRoomHandle): NetcodeRuntime;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
function normalized(value) {
|
|
2
|
+
return value.trim().toUpperCase();
|
|
3
|
+
}
|
|
4
|
+
function assertRoom(room, requested) {
|
|
5
|
+
if (normalized(room.id) !== normalized(requested)) {
|
|
6
|
+
throw new Error(`Activated OpenRTC room '${room.id}' cannot join '${requested}'.`);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function peerMember(peer) {
|
|
10
|
+
const peerId = String(peer.nodeId ?? peer.id ?? peer.deviceId ?? '');
|
|
11
|
+
return {
|
|
12
|
+
nodeId: peerId,
|
|
13
|
+
userId: typeof peer.userId === 'string' ? peer.userId : undefined,
|
|
14
|
+
ticket: typeof peer.ticket === 'string' ? peer.ticket : undefined,
|
|
15
|
+
joinedAt: typeof peer.connectedAt === 'number' ? peer.connectedAt : Date.now(),
|
|
16
|
+
lastSeenAt: Date.now(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Adapts one OpenRTC 2.0 capability handle to netcode's transport-only runtime
|
|
21
|
+
* contract. It never creates a second room, user scope, or base space.
|
|
22
|
+
*/
|
|
23
|
+
export function runtimeFromRoom(room) {
|
|
24
|
+
const connections = () => room.diagnostics.connections();
|
|
25
|
+
return {
|
|
26
|
+
managedConnectionLifecycle: true,
|
|
27
|
+
channels: room.channels,
|
|
28
|
+
initialize: async () => undefined,
|
|
29
|
+
start: async () => undefined,
|
|
30
|
+
stop: async () => undefined,
|
|
31
|
+
getNodeId: async () => {
|
|
32
|
+
const status = await room.diagnostics.status();
|
|
33
|
+
const nodeId = typeof status.localNodeId === 'string' ? status.localNodeId.trim() : '';
|
|
34
|
+
if (!nodeId)
|
|
35
|
+
throw new Error('The activated OpenRTC room has no local peer identity yet.');
|
|
36
|
+
return nodeId;
|
|
37
|
+
},
|
|
38
|
+
createRoom: async (roomId = room.id) => {
|
|
39
|
+
assertRoom(room, roomId);
|
|
40
|
+
return normalized(room.id);
|
|
41
|
+
},
|
|
42
|
+
joinRoom: async (roomId) => {
|
|
43
|
+
assertRoom(room, roomId);
|
|
44
|
+
return connections();
|
|
45
|
+
},
|
|
46
|
+
leaveRoom: async (roomId) => {
|
|
47
|
+
assertRoom(room, roomId);
|
|
48
|
+
await room.leave();
|
|
49
|
+
},
|
|
50
|
+
watchRoom: (roomId, callback) => {
|
|
51
|
+
assertRoom(room, roomId);
|
|
52
|
+
return room.peers.watch((peers) => callback(peers.map((peer) => peerMember(peer))));
|
|
53
|
+
},
|
|
54
|
+
getRoomMembers: async (roomId) => {
|
|
55
|
+
assertRoom(room, roomId);
|
|
56
|
+
return room.peers.listConnected()
|
|
57
|
+
.map((peer) => peerMember(peer));
|
|
58
|
+
},
|
|
59
|
+
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
|
+
});
|
|
72
|
+
},
|
|
73
|
+
getConnections: connections,
|
|
74
|
+
connectByTicket: ({ ticket, timeoutMs }) => room.peers.connect({ ticket, timeoutMs }),
|
|
75
|
+
// The room capability already authorized the peer route. A protocol
|
|
76
|
+
// channel is not another admission avenue and must not replace that
|
|
77
|
+
// lifecycle scope with the channel identifier (which would trigger a
|
|
78
|
+
// second scoped dial on every send while the new scope is absent).
|
|
79
|
+
connectScopedChannel: (options) => room.channels.connectScoped({
|
|
80
|
+
...options,
|
|
81
|
+
scope: `v2:room:${room.id}`,
|
|
82
|
+
existingRouteOnly: true,
|
|
83
|
+
}),
|
|
84
|
+
onIncomingChannelStream: (channelId, callback) => room.channels.onIncomingChannel(channelId, callback),
|
|
85
|
+
getApplicationReadyConnections: () => connections().filter((connection) => typeof connection.isReadyForApplicationPayload !== 'function'
|
|
86
|
+
|| connection.isReadyForApplicationPayload()),
|
|
87
|
+
};
|
|
88
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,26 +1,41 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { RuntimeApplicationPayloadReadinessOptions as OpenRtcApplicationPayloadReadinessOptions, RuntimeIncomingStream, RuntimeScopedChannelOptions, RuntimeScopedLogicalChannel } from 'openrtc/runtime';
|
|
2
|
+
import type { OpenRTCRoomHandle } from 'openrtc';
|
|
3
3
|
import type { MetadataBody, NetcodeEnvelope, ObjectRemoveBody, ObjectSnapshotBody, ObjectUpsertBody, StatePatchBody, StateSnapshotBody, UserMessageBody, VoiceSignalBody } from './protocol.js';
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
export interface NetcodeRuntime {
|
|
5
|
+
/**
|
|
6
|
+
* The activated capability owns discovery and physical peer lifecycle.
|
|
7
|
+
* Protocol layers must observe its connections instead of issuing a
|
|
8
|
+
* second ticket dial for the same avenue.
|
|
9
|
+
*/
|
|
10
|
+
managedConnectionLifecycle?: boolean;
|
|
11
|
+
initialize?(): Promise<void> | void;
|
|
12
|
+
start?(): Promise<void> | void;
|
|
13
|
+
stop?(): Promise<void> | void;
|
|
14
|
+
getNodeId(): Promise<string>;
|
|
15
|
+
createRoom(roomId?: string): Promise<string>;
|
|
16
|
+
joinRoom(roomId: string, options?: {
|
|
17
|
+
bootstrapPeers?: boolean;
|
|
18
|
+
}): Promise<RuntimeConnectionLike[]>;
|
|
19
|
+
leaveRoom(roomId: string): Promise<void>;
|
|
20
|
+
watchRoom(roomId: string, callback: (members: RuntimeMemberLike[]) => void): () => void;
|
|
21
|
+
getRoomMembers(roomId: string): Promise<RuntimeMemberLike[]>;
|
|
22
|
+
onConnection(callback: (connection: RuntimeConnectionLike) => void): () => void;
|
|
23
|
+
getConnections(): RuntimeConnectionLike[];
|
|
24
|
+
connectByTicket(target: {
|
|
25
|
+
ticket: string;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
}): Promise<RuntimeConnectionLike>;
|
|
28
|
+
connectScopedChannel(options: RuntimeScopedChannelOptions): Promise<RuntimeScopedLogicalChannel>;
|
|
29
|
+
onIncomingChannelStream(channelId: string, callback: (stream: RuntimeIncomingStream) => boolean | void): () => void;
|
|
30
|
+
getApplicationReadyConnections(options?: RuntimeApplicationPayloadReadinessOptions): RuntimeConnectionLike[];
|
|
31
|
+
channels?: OpenRTCRoomHandle['channels'];
|
|
32
|
+
}
|
|
6
33
|
export type NetcodeReliability = 'reliable';
|
|
7
34
|
export interface NetcodeOptions {
|
|
8
35
|
runtime?: NetcodeRuntime;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
authMode?: OpenrtcClientOptions['authMode'];
|
|
12
|
-
discoveryMode?: OpenrtcClientOptions['discoveryMode'];
|
|
13
|
-
allowAnonymousHostedDefaults?: OpenrtcClientOptions['allowAnonymousHostedDefaults'];
|
|
14
|
-
space?: OpenrtcClientOptions['space'];
|
|
15
|
-
spaceKey?: OpenrtcClientOptions['spaceKey'];
|
|
16
|
-
spaceTokenProvider?: NonNullable<OpenrtcClientOptions>['spaceTokenProvider'];
|
|
17
|
-
storagePrefix?: OpenrtcClientOptions['storagePrefix'];
|
|
18
|
-
nodeIdPersistence?: OpenrtcClientOptions['nodeIdPersistence'];
|
|
19
|
-
transports?: OpenrtcClientOptions['transports'];
|
|
20
|
-
transportPriority?: OpenrtcClientOptions['transportPriority'];
|
|
36
|
+
/** Preferred 2.0 path: one already-activated ephemeral match room. */
|
|
37
|
+
room?: OpenRTCRoomHandle;
|
|
21
38
|
preferredPayloadTransports?: RuntimeApplicationPayloadReadinessOptions['preferredTransports'];
|
|
22
|
-
strictMode?: OpenrtcClientOptions['strictMode'];
|
|
23
|
-
turnCredentialsProvider?: NonNullable<OpenrtcClientOptions>['turnCredentialsProvider'];
|
|
24
39
|
displayName?: string;
|
|
25
40
|
maxPeers?: number;
|
|
26
41
|
localFallback?: boolean;
|
|
@@ -235,16 +250,15 @@ export type RuntimeConnectionLike = {
|
|
|
235
250
|
parallelTransport?: string | null;
|
|
236
251
|
};
|
|
237
252
|
getAvailableTransports?(): string[];
|
|
253
|
+
isReadyForApplicationPayload?(options?: RuntimeApplicationPayloadReadinessOptions): boolean;
|
|
254
|
+
onUpgradeStateChange?(callback: (state: 'none' | 'upgrading' | 'upgraded' | 'failed') => void): void;
|
|
238
255
|
requestWebRTCUpgrade?(reason?: string): void;
|
|
239
256
|
send(message: unknown): Promise<void> | void;
|
|
240
257
|
onMessage(callback: (message: unknown) => void): void | (() => void);
|
|
241
258
|
onDisconnect(callback: () => void): void | (() => void);
|
|
242
259
|
disconnect?(): Promise<void> | void;
|
|
243
260
|
};
|
|
244
|
-
export type RuntimeApplicationPayloadReadinessOptions =
|
|
245
|
-
preferredTransports?: string[];
|
|
246
|
-
allowFallbackAfterPreferredTransportFailure?: boolean;
|
|
247
|
-
};
|
|
261
|
+
export type RuntimeApplicationPayloadReadinessOptions = OpenRtcApplicationPayloadReadinessOptions;
|
|
248
262
|
export type RuntimeMemberLike = {
|
|
249
263
|
nodeId?: string;
|
|
250
264
|
userId?: string;
|
|
@@ -255,4 +269,3 @@ export type RuntimeMemberLike = {
|
|
|
255
269
|
metadata?: string | Record<string, unknown> | null;
|
|
256
270
|
};
|
|
257
271
|
export type RuntimeEnvelopeBody = UserMessageBody | StateSnapshotBody | StatePatchBody | ObjectSnapshotBody | ObjectUpsertBody | ObjectRemoveBody | MetadataBody | VoiceSignalBody | Record<string, unknown>;
|
|
258
|
-
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openrtc-netcode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0-rc.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": "
|
|
45
|
+
"openrtc": "2.0.0-rc.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^24.10.1",
|