openrtc-netcode 3.0.1 → 3.0.3
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 +181 -40
- package/dist/actions.d.ts +40 -0
- package/dist/actions.js +269 -0
- package/dist/client.js +1049 -294
- package/dist/codec.d.ts +74 -0
- package/dist/codec.js +167 -0
- package/dist/framing.d.ts +9 -0
- package/dist/framing.js +39 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/dist/latestState.d.ts +4 -1
- package/dist/latestState.js +23 -19
- package/dist/motion.d.ts +9 -0
- package/dist/motion.js +12 -0
- package/dist/objects.d.ts +34 -5
- package/dist/objects.js +276 -38
- package/dist/protocol.d.ts +15 -2
- package/dist/protocol.js +20 -5
- package/dist/replication.d.ts +31 -0
- package/dist/replication.js +135 -0
- package/dist/roomRuntime.js +18 -16
- package/dist/simulation.d.ts +80 -0
- package/dist/simulation.js +269 -0
- package/dist/snapshots.d.ts +12 -0
- package/dist/snapshots.js +71 -0
- package/dist/state.d.ts +17 -4
- package/dist/state.js +162 -51
- package/dist/types.d.ts +50 -3
- package/dist/validation.d.ts +10 -0
- package/dist/validation.js +88 -0
- package/dist/voice.d.ts +2 -0
- package/dist/voice.js +81 -39
- package/package.json +2 -2
package/dist/client.js
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import { TypedEmitter } from './events.js';
|
|
2
2
|
import { decodeEnvelope, encodeEnvelope, isRecord, NETCODE_PROTOCOL, PROTOCOL_VERSION, } from './protocol.js';
|
|
3
|
-
import { isNetcodeObjectState, ObjectStore } from './objects.js';
|
|
3
|
+
import { isNetcodeObjectState, isObjectRemoval, ObjectStore } from './objects.js';
|
|
4
4
|
import { ReplicatedState } from './state.js';
|
|
5
5
|
import { NetcodeVoiceController } from './voice.js';
|
|
6
6
|
import { runtimeFromRoom } from './roomRuntime.js';
|
|
7
7
|
import { LatestStateQueue } from './latestState.js';
|
|
8
|
+
import { encodeMotion, decodeMotion, encodeJsonMotion, decodeJsonMotion, jsonMotionRecord, motionToBase64, motionFromBase64 } from './codec.js';
|
|
9
|
+
import { canonicalJson, boundedJson, cloneJson, MAX_FRAME_BYTES } from './validation.js';
|
|
10
|
+
import { snapshotPages, SnapshotAssembly } from './snapshots.js';
|
|
11
|
+
import { freezeJson } from './validation.js';
|
|
12
|
+
import { FrameDecoder } from './framing.js';
|
|
13
|
+
import { SimulationClock } from './simulation.js';
|
|
14
|
+
import { ActionRegistry } from './actions.js';
|
|
15
|
+
import { motionChanged } from './motion.js';
|
|
16
|
+
import { ReplicationScheduler } from './replication.js';
|
|
8
17
|
const DEFAULT_MAX_PEERS = 8;
|
|
9
|
-
const NETCODE_CHANNEL_ID = 'openrtc-netcode/
|
|
18
|
+
const NETCODE_CHANNEL_ID = 'openrtc-netcode/v2';
|
|
10
19
|
const PING_INTERVAL_MS = 2000;
|
|
11
20
|
const MAX_SEEN_ENVELOPES = 2048;
|
|
21
|
+
const MOTION_CHANNEL_ID = 'openrtc-netcode/v2/motion';
|
|
12
22
|
const CHANNEL_OPEN_TIMEOUT_MS = 15000;
|
|
13
|
-
const UPGRADED_PAYLOAD_TRANSPORTS = new Set(['webrtc', 'moq']);
|
|
14
23
|
const textEncoder = new TextEncoder();
|
|
15
24
|
const textDecoder = new TextDecoder();
|
|
16
25
|
class ClientImpl {
|
|
@@ -20,11 +29,47 @@ class ClientImpl {
|
|
|
20
29
|
this.localPeerId = null;
|
|
21
30
|
this.currentLobby = null;
|
|
22
31
|
this.currentRoomId = null;
|
|
23
|
-
this.fallbackChannel = null;
|
|
24
32
|
this.lobbyMetadata = {};
|
|
33
|
+
this.lobbyMetadataRevision = 0;
|
|
34
|
+
this.lobbyMetadataOwner = null;
|
|
25
35
|
this.memberMetadata = {};
|
|
36
|
+
this.memberMetadataRevision = 1;
|
|
37
|
+
this.memberMetadataRevisions = new Map();
|
|
26
38
|
this.seq = 0;
|
|
27
39
|
this.started = false;
|
|
40
|
+
this.starting = null;
|
|
41
|
+
this.stopping = null;
|
|
42
|
+
this.lobbyOperation = 0;
|
|
43
|
+
this.activeLobbyOperation = 0;
|
|
44
|
+
this.desiredRoomId = null;
|
|
45
|
+
this.lobbyCleanup = null;
|
|
46
|
+
this.negotiations = new Map();
|
|
47
|
+
this.lastMotion = new Map();
|
|
48
|
+
// One bounded set of allowed IDs per active recipient, retained across
|
|
49
|
+
// view changes so removals also fence a baseline still in flight.
|
|
50
|
+
this.exposedObjectIds = new Map();
|
|
51
|
+
this.snapshotRevisions = new Map();
|
|
52
|
+
this.snapshotSends = new Map();
|
|
53
|
+
this.snapshotAssemblies = new Map();
|
|
54
|
+
this.matchGeneration = 0;
|
|
55
|
+
this.authorityEpoch = crypto.randomUUID();
|
|
56
|
+
this.peerAuthorityEpochs = new Map();
|
|
57
|
+
this.peerHelloSequences = new Map();
|
|
58
|
+
this.retiredAuthorityEpochs = new Set();
|
|
59
|
+
this.authorityEpochExhausted = false;
|
|
60
|
+
this.activeHostEpoch = null;
|
|
61
|
+
this.sessionId = '';
|
|
62
|
+
this.memberIds = new Set();
|
|
63
|
+
this.peerGenerations = new Map();
|
|
64
|
+
this.peerSessions = new Map();
|
|
65
|
+
this.retiredSessions = new Set();
|
|
66
|
+
this.connectionSubscriptions = new Map();
|
|
67
|
+
this.streamReaders = new Map();
|
|
68
|
+
this.membership = [];
|
|
69
|
+
this.actions = new ActionRegistry((peerId, kind, body) => this.sendEnvelope(peerId, kind, body));
|
|
70
|
+
this.sentViews = new Map();
|
|
71
|
+
this.receivedViews = new Map();
|
|
72
|
+
this.encodedEnvelopes = new WeakMap();
|
|
28
73
|
this.stopped = false;
|
|
29
74
|
this.stopWatchingRoom = null;
|
|
30
75
|
this.stopRuntimeConnections = null;
|
|
@@ -35,19 +80,154 @@ class ClientImpl {
|
|
|
35
80
|
this.peersById = new Map();
|
|
36
81
|
this.peerStatsById = new Map();
|
|
37
82
|
this.connectionsByPeer = new Map();
|
|
83
|
+
this.motionChannelsByPeer = new Map();
|
|
84
|
+
this.pendingMotionChannelsByPeer = new Map();
|
|
85
|
+
this.pendingAdmissions = new Map();
|
|
38
86
|
this.channelsByPeer = new Map();
|
|
39
87
|
this.pendingChannelsByPeer = new Map();
|
|
40
88
|
this.connectionIds = new Set();
|
|
41
|
-
this.directSendWarnings = new Set();
|
|
42
89
|
this.introPeerIds = new Set();
|
|
43
90
|
this.seenEnvelopeIds = new Set();
|
|
44
91
|
this.seenEnvelopeOrder = [];
|
|
45
92
|
this.latestStateQueues = new Map();
|
|
46
|
-
this.stateStore = new ReplicatedState((options) => this.resolveStateOwner(options), (key, value, owner) => this.broadcastLatestState(`state:${key}`, 'state.patch', { key, value, owner }));
|
|
47
|
-
this.objectStore = new ObjectStore(() => this.localPeerId, (object) => this.
|
|
93
|
+
this.stateStore = new ReplicatedState((options) => this.resolveStateOwner(options), (key, value, owner, revision) => this.broadcastLatestState(`state:${key}`, 'state.patch', { key, value, owner, revision }), 4096, (owner) => owner === this.localPeerId && this.isLocalMatchActive() && (this.options.authority?.mode !== 'host' || (!this.authorityEpochExhausted && owner === this.currentLobby?.ownerId)), (options) => !this.currentLobby?.ownerId && (this.options.authority?.mode === 'host' || options?.owner === 'lobby-owner'));
|
|
94
|
+
this.objectStore = new ObjectStore(() => this.localPeerId, (object) => this.publishObject(object), async (removal) => {
|
|
95
|
+
for (const peerId of this.memberIds) {
|
|
96
|
+
this.replication.cancel(peerId, removal.id);
|
|
97
|
+
this.lastMotion.get(peerId)?.delete(removal.id);
|
|
98
|
+
}
|
|
99
|
+
await Promise.all([...this.memberIds]
|
|
100
|
+
.filter((peerId) => this.exposedObjectIds.get(peerId)?.has(removal.id))
|
|
101
|
+
.map((peerId) => this.sendEnvelope(peerId, 'object.remove', removal)));
|
|
102
|
+
}, 10000, (kind) => this.objectIncarnation(this.sessionId || 'unjoined', this.authorityEpoch, kind), () => this.clock.tick, (kind) => this.isLocalMatchActive() && (!this.requiresHost(kind) || (!this.authorityEpochExhausted && this.currentLobby?.ownerId === this.localPeerId)));
|
|
48
103
|
this.voiceController = new NetcodeVoiceController(() => this.localPeerId, () => this.peers, (peerId) => this.connectionsByPeer.get(peerId)
|
|
49
104
|
?? this.runtime?.getConnections().find((connection) => connection.remoteNodeId === peerId || connection.deviceId === peerId)
|
|
50
105
|
?? null);
|
|
106
|
+
this.clock = new SimulationClock(options.tickRate);
|
|
107
|
+
if (options.motionThresholds && Object.values(options.motionThresholds).some((value) => !Number.isFinite(value) || value < 0))
|
|
108
|
+
throw new Error('Invalid motion thresholds.');
|
|
109
|
+
this.replication = new ReplicationScheduler(async (peerId, objects) => {
|
|
110
|
+
const generation = this.matchGeneration, authorityEpoch = this.authorityEpoch;
|
|
111
|
+
const view = this.sentViews.get(peerId);
|
|
112
|
+
if (!view || !this.currentRoomId)
|
|
113
|
+
throw new Error('Replication match retired.');
|
|
114
|
+
const visible = objects.filter((object) => view.ids.has(object.id));
|
|
115
|
+
if (!visible.length)
|
|
116
|
+
return;
|
|
117
|
+
const body = this.options.codec === 'quantized'
|
|
118
|
+
? { packed: motionToBase64(encodeMotion(visible, [...view.ids])), viewRevision: view.revision }
|
|
119
|
+
: { motion: encodeJsonMotion(visible, [...view.ids]), viewRevision: view.revision };
|
|
120
|
+
const envelope = this.createEnvelope('object.batch', body, peerId);
|
|
121
|
+
const encoded = this.encodedEnvelope(envelope);
|
|
122
|
+
await this.sendDirectEnvelope(peerId, envelope, true);
|
|
123
|
+
if (generation !== this.matchGeneration || authorityEpoch !== this.authorityEpoch)
|
|
124
|
+
throw new Error('Replication match retired.');
|
|
125
|
+
let last = this.lastMotion.get(peerId);
|
|
126
|
+
if (!last) {
|
|
127
|
+
last = new Map();
|
|
128
|
+
this.lastMotion.set(peerId, last);
|
|
129
|
+
}
|
|
130
|
+
for (const object of visible)
|
|
131
|
+
last.set(object.id, { object, time: performance.now() });
|
|
132
|
+
return textEncoder.encode(encoded).byteLength;
|
|
133
|
+
}, options.replication);
|
|
134
|
+
}
|
|
135
|
+
waitForPeer(peerId, timeoutMs = 15000) {
|
|
136
|
+
if (!this.memberIds.has(peerId) || peerId === this.localPeerId)
|
|
137
|
+
return Promise.reject(new Error('Peer is outside the match.'));
|
|
138
|
+
if (this.peerSessions.has(peerId))
|
|
139
|
+
return Promise.resolve();
|
|
140
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 60000)
|
|
141
|
+
return Promise.reject(new Error('Invalid peer negotiation timeout.'));
|
|
142
|
+
const existing = this.negotiations.get(peerId);
|
|
143
|
+
if (existing)
|
|
144
|
+
return existing;
|
|
145
|
+
const generation = this.matchGeneration;
|
|
146
|
+
const pending = new Promise((resolve, reject) => {
|
|
147
|
+
const stops = [];
|
|
148
|
+
const finish = (error) => {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
for (const stop of stops)
|
|
151
|
+
stop();
|
|
152
|
+
if (error)
|
|
153
|
+
reject(error);
|
|
154
|
+
else
|
|
155
|
+
resolve();
|
|
156
|
+
};
|
|
157
|
+
const timer = setTimeout(() => finish(new Error('Netcode v2 peer negotiation timed out.')), timeoutMs);
|
|
158
|
+
stops.push(this.emitter.on('envelope', (envelope) => {
|
|
159
|
+
if (envelope.from === peerId && envelope.kind === 'hello' && this.peerSessions.has(peerId))
|
|
160
|
+
finish();
|
|
161
|
+
}));
|
|
162
|
+
stops.push(this.emitter.on('protocol:error', (error) => {
|
|
163
|
+
if (error.peerId === peerId && error.reason === 'incompatible-version')
|
|
164
|
+
finish(new Error('Incompatible netcode protocol version.'));
|
|
165
|
+
}));
|
|
166
|
+
stops.push(this.emitter.on('lobby', () => {
|
|
167
|
+
if (generation !== this.matchGeneration)
|
|
168
|
+
finish(new Error('Netcode match retired.'));
|
|
169
|
+
}));
|
|
170
|
+
stops.push(this.emitter.on('peer:left', (peer) => {
|
|
171
|
+
if (peer.id === peerId)
|
|
172
|
+
finish(new Error('Peer left during negotiation.'));
|
|
173
|
+
}));
|
|
174
|
+
}).finally(() => { if (this.negotiations.get(peerId) === pending)
|
|
175
|
+
this.negotiations.delete(peerId); });
|
|
176
|
+
this.negotiations.set(peerId, pending);
|
|
177
|
+
return pending;
|
|
178
|
+
}
|
|
179
|
+
handleAction(type, handler) { return this.actions.handle(type, handler); }
|
|
180
|
+
async request(peerId, type, payload, options) {
|
|
181
|
+
const generation = this.matchGeneration, authorityEpoch = this.authorityEpoch;
|
|
182
|
+
const recipientEpoch = this.peerAuthorityEpochs.get(peerId);
|
|
183
|
+
await this.waitForPeer(peerId);
|
|
184
|
+
if (authorityEpoch !== this.authorityEpoch || (recipientEpoch && recipientEpoch !== this.peerAuthorityEpochs.get(peerId)))
|
|
185
|
+
throw new Error('Action authority retired.');
|
|
186
|
+
if (generation !== this.matchGeneration)
|
|
187
|
+
throw new Error('Netcode match retired.');
|
|
188
|
+
return this.actions.request(peerId, type, payload, options);
|
|
189
|
+
}
|
|
190
|
+
flushReplication() { this.replication.flush(); }
|
|
191
|
+
replicationStats(peerId) { return this.replication.diagnostics(peerId); }
|
|
192
|
+
async refreshInterest() {
|
|
193
|
+
const sends = [];
|
|
194
|
+
for (const peerId of this.memberIds) {
|
|
195
|
+
if (peerId === this.localPeerId)
|
|
196
|
+
continue;
|
|
197
|
+
const ids = new Set(this.objectStore.list().filter((object) => object.ownerId === this.localPeerId
|
|
198
|
+
&& (this.options.interest?.(peerId, object) ?? true)).map((object) => object.id));
|
|
199
|
+
const previous = this.sentViews.get(peerId);
|
|
200
|
+
if (!previous || ids.size !== previous.ids.size || [...ids].some((id) => !previous.ids.has(id)))
|
|
201
|
+
sends.push(this.sendObjectSnapshot(peerId));
|
|
202
|
+
}
|
|
203
|
+
await Promise.all(sends);
|
|
204
|
+
this.replication.flush();
|
|
205
|
+
}
|
|
206
|
+
async publishObject(object) {
|
|
207
|
+
const generation = this.matchGeneration, authorityEpoch = this.authorityEpoch;
|
|
208
|
+
const assertCurrent = () => {
|
|
209
|
+
if (generation !== this.matchGeneration || authorityEpoch !== this.authorityEpoch)
|
|
210
|
+
throw new Error('Replication authority retired.');
|
|
211
|
+
};
|
|
212
|
+
const sends = Array.from(this.memberIds).filter((peerId) => peerId !== this.localPeerId).map(async (peerId) => {
|
|
213
|
+
await this.waitForPeer(peerId);
|
|
214
|
+
assertCurrent();
|
|
215
|
+
const interested = this.options.interest?.(peerId, object) ?? true;
|
|
216
|
+
const view = this.sentViews.get(peerId);
|
|
217
|
+
if (!view || view.ids.has(object.id) !== interested || (interested && view.signatures?.get(object.id) !== this.objectSignature(object))) {
|
|
218
|
+
await this.sendObjectSnapshot(peerId);
|
|
219
|
+
}
|
|
220
|
+
assertCurrent();
|
|
221
|
+
if (!interested)
|
|
222
|
+
return;
|
|
223
|
+
const last = this.lastMotion.get(peerId)?.get(object.id);
|
|
224
|
+
if (last && this.options.motionThresholds && this.objectSignature(last.object) === this.objectSignature(object)
|
|
225
|
+
&& !motionChanged(last.object, object, performance.now() - last.time, this.options.motionThresholds))
|
|
226
|
+
return;
|
|
227
|
+
const bytes = this.options.codec === 'quantized' ? 80 : textEncoder.encode(JSON.stringify(jsonMotionRecord(object, 9999))).byteLength + 32;
|
|
228
|
+
await this.replication.enqueue(peerId, object.id, object, bytes, this.options.priority?.(peerId, object) ?? 0);
|
|
229
|
+
});
|
|
230
|
+
await Promise.all(sends);
|
|
51
231
|
}
|
|
52
232
|
get peerId() {
|
|
53
233
|
return this.localPeerId;
|
|
@@ -71,10 +251,28 @@ class ClientImpl {
|
|
|
71
251
|
return this.voiceController;
|
|
72
252
|
}
|
|
73
253
|
async start() {
|
|
74
|
-
if (this.
|
|
75
|
-
|
|
254
|
+
if (this.stopping) {
|
|
255
|
+
await this.stopping;
|
|
256
|
+
return this.start();
|
|
76
257
|
}
|
|
77
|
-
this.
|
|
258
|
+
if (this.starting)
|
|
259
|
+
return this.starting;
|
|
260
|
+
if (this.started)
|
|
261
|
+
return;
|
|
262
|
+
const opening = this.initialize().catch(async (error) => {
|
|
263
|
+
this.stopRuntimeConnections?.();
|
|
264
|
+
this.stopRuntimeConnections = null;
|
|
265
|
+
this.stopIncomingNetcodeChannel?.();
|
|
266
|
+
this.stopIncomingNetcodeChannel = null;
|
|
267
|
+
await this.runtime?.dispose?.();
|
|
268
|
+
this.runtime = null;
|
|
269
|
+
this.localPeerId = null;
|
|
270
|
+
throw error;
|
|
271
|
+
}).finally(() => { this.starting = null; });
|
|
272
|
+
this.starting = opening;
|
|
273
|
+
return opening;
|
|
274
|
+
}
|
|
275
|
+
async initialize() {
|
|
78
276
|
this.stopped = false;
|
|
79
277
|
if (!this.options.runtime && !this.options.room) {
|
|
80
278
|
throw new Error('openrtc-netcode requires an activated OpenRTC 2.0 room adapter or an explicit openrtc/runtime client.');
|
|
@@ -89,24 +287,30 @@ class ClientImpl {
|
|
|
89
287
|
this.stopRuntimeConnections = this.runtime.onConnection((connection) => {
|
|
90
288
|
this.attachConnection(connection);
|
|
91
289
|
});
|
|
92
|
-
|
|
93
|
-
this.attachConnection(connection);
|
|
94
|
-
}
|
|
290
|
+
this.started = true;
|
|
95
291
|
}
|
|
96
|
-
|
|
292
|
+
stop() {
|
|
293
|
+
this.lobbyOperation++;
|
|
294
|
+
this.desiredRoomId = null;
|
|
295
|
+
if (this.stopping)
|
|
296
|
+
return this.stopping;
|
|
97
297
|
this.stopped = true;
|
|
98
|
-
|
|
298
|
+
const closing = Promise.resolve().then(() => this.dispose()).finally(() => { if (this.stopping === closing)
|
|
299
|
+
this.stopping = null; });
|
|
300
|
+
this.stopping = closing;
|
|
301
|
+
return closing;
|
|
302
|
+
}
|
|
303
|
+
async dispose() {
|
|
304
|
+
await this.starting?.catch(() => undefined);
|
|
305
|
+
this.stopped = true;
|
|
306
|
+
await this.leaveCurrentLobby();
|
|
99
307
|
this.stopRuntimeConnections?.();
|
|
100
308
|
this.stopRuntimeConnections = null;
|
|
101
309
|
this.stopIncomingNetcodeChannel?.();
|
|
102
310
|
this.stopIncomingNetcodeChannel = null;
|
|
103
311
|
await this.closeNetcodeChannels();
|
|
104
|
-
for (const connection of this.connectionsByPeer.values()) {
|
|
105
|
-
await connection.disconnect?.();
|
|
106
|
-
}
|
|
107
312
|
this.connectionsByPeer.clear();
|
|
108
313
|
this.connectionIds.clear();
|
|
109
|
-
this.directSendWarnings.clear();
|
|
110
314
|
this.introPeerIds.clear();
|
|
111
315
|
this.peerStatsById.clear();
|
|
112
316
|
this.seenEnvelopeIds.clear();
|
|
@@ -116,110 +320,268 @@ class ClientImpl {
|
|
|
116
320
|
this.peersById.clear();
|
|
117
321
|
this.objectStore.clear();
|
|
118
322
|
await this.voiceController.stop();
|
|
119
|
-
this.
|
|
120
|
-
this.messageListeners.clear();
|
|
323
|
+
await this.runtime?.dispose?.();
|
|
121
324
|
this.runtime = null;
|
|
325
|
+
this.localPeerId = null;
|
|
326
|
+
this.stateStore.clear();
|
|
122
327
|
this.started = false;
|
|
123
328
|
}
|
|
124
329
|
async createLobby(options = {}) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
330
|
+
const operation = this.beginLobbyOperation(null);
|
|
331
|
+
try {
|
|
332
|
+
await this.start();
|
|
333
|
+
this.assertLobbyOperation(operation);
|
|
334
|
+
await this.leaveCurrentLobby();
|
|
335
|
+
this.assertLobbyOperation(operation);
|
|
336
|
+
const runtime = this.requireRuntime();
|
|
337
|
+
const lobbyId = await runtime.createRoom(options.id);
|
|
338
|
+
this.assertLobbyOperation(operation, runtime);
|
|
339
|
+
this.desiredRoomId = normalizeLobbyId(lobbyId);
|
|
340
|
+
this.lobbyMetadata = { ...(options.metadata ?? {}) };
|
|
341
|
+
this.lobbyMetadataRevision = 1;
|
|
342
|
+
return await this.joinAndActivateLobby(runtime, lobbyId, operation);
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
this.clearFailedLobbyIntent(operation);
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
135
348
|
}
|
|
136
349
|
async joinLobby(lobbyId, options = {}) {
|
|
137
|
-
await this.start();
|
|
138
|
-
const runtime = this.requireRuntime();
|
|
139
|
-
await this.leaveLobby();
|
|
140
350
|
const roomId = normalizeLobbyId(lobbyId);
|
|
141
|
-
|
|
142
|
-
await runtime.joinRoom(roomId, { bootstrapPeers: false });
|
|
143
|
-
return this.activateLobby(roomId);
|
|
144
|
-
}
|
|
145
|
-
async joinOrCreateLobby(lobbyId, options = {}) {
|
|
351
|
+
const operation = this.beginLobbyOperation(roomId);
|
|
146
352
|
try {
|
|
147
|
-
|
|
353
|
+
await this.start();
|
|
354
|
+
this.assertLobbyOperation(operation);
|
|
355
|
+
await this.leaveCurrentLobby();
|
|
356
|
+
this.assertLobbyOperation(operation);
|
|
357
|
+
this.memberMetadata = { ...(options.metadata ?? {}) };
|
|
358
|
+
return await this.joinAndActivateLobby(this.requireRuntime(), roomId, operation);
|
|
148
359
|
}
|
|
149
360
|
catch (error) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
361
|
+
this.clearFailedLobbyIntent(operation);
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async joinOrCreateLobby(lobbyId, options = {}) {
|
|
366
|
+
const roomId = normalizeLobbyId(lobbyId);
|
|
367
|
+
const operation = this.beginLobbyOperation(roomId);
|
|
368
|
+
try {
|
|
369
|
+
await this.start();
|
|
370
|
+
this.assertLobbyOperation(operation);
|
|
371
|
+
await this.leaveCurrentLobby();
|
|
372
|
+
this.assertLobbyOperation(operation);
|
|
155
373
|
this.memberMetadata = { ...(options.metadata ?? {}) };
|
|
374
|
+
const runtime = this.requireRuntime();
|
|
375
|
+
try {
|
|
376
|
+
return await this.joinAndActivateLobby(runtime, roomId, operation);
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
this.assertLobbyOperation(operation, runtime);
|
|
380
|
+
if (!isRoomNotFoundError(error))
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
156
383
|
try {
|
|
157
384
|
const createdRoomId = await runtime.createRoom(roomId);
|
|
158
|
-
|
|
385
|
+
this.assertLobbyOperation(operation, runtime);
|
|
386
|
+
this.desiredRoomId = normalizeLobbyId(createdRoomId);
|
|
387
|
+
return await this.joinAndActivateLobby(runtime, createdRoomId, operation);
|
|
159
388
|
}
|
|
160
389
|
catch (createError) {
|
|
161
|
-
|
|
390
|
+
this.assertLobbyOperation(operation, runtime);
|
|
391
|
+
if (!isRoomAlreadyExistsError(createError))
|
|
162
392
|
throw createError;
|
|
163
|
-
}
|
|
164
393
|
}
|
|
165
|
-
return this.joinLobbyWithRetry(roomId,
|
|
394
|
+
return await this.joinLobbyWithRetry(runtime, roomId, operation);
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
this.clearFailedLobbyIntent(operation);
|
|
398
|
+
throw error;
|
|
166
399
|
}
|
|
167
400
|
}
|
|
168
|
-
|
|
169
|
-
|
|
401
|
+
beginLobbyOperation(roomId) {
|
|
402
|
+
this.desiredRoomId = roomId;
|
|
403
|
+
return ++this.lobbyOperation;
|
|
404
|
+
}
|
|
405
|
+
assertLobbyOperation(operation, runtime) {
|
|
406
|
+
if (operation !== this.lobbyOperation || this.stopped || this.stopping || (runtime && runtime !== this.runtime)) {
|
|
407
|
+
throw new Error('Lobby operation superseded.');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
clearFailedLobbyIntent(operation) {
|
|
411
|
+
if (operation === this.lobbyOperation)
|
|
412
|
+
this.desiredRoomId = null;
|
|
413
|
+
}
|
|
414
|
+
async joinAndActivateLobby(runtime, lobbyId, operation) {
|
|
415
|
+
this.assertLobbyOperation(operation, runtime);
|
|
416
|
+
const roomId = normalizeLobbyId(lobbyId);
|
|
417
|
+
// The borrowed runtime owns admission. Its API has no cancellation
|
|
418
|
+
// signal, so a superseded completion releases only its obsolete room.
|
|
419
|
+
let admissions = this.pendingAdmissions.get(runtime);
|
|
420
|
+
if (!admissions) {
|
|
421
|
+
admissions = new Map();
|
|
422
|
+
this.pendingAdmissions.set(runtime, admissions);
|
|
423
|
+
}
|
|
424
|
+
let admission = admissions.get(roomId);
|
|
425
|
+
if (!admission) {
|
|
426
|
+
admission = Promise.resolve(runtime.joinRoom(roomId, { bootstrapPeers: false }));
|
|
427
|
+
admissions.set(roomId, admission);
|
|
428
|
+
const current = admission;
|
|
429
|
+
void admission.finally(() => {
|
|
430
|
+
if (admissions.get(roomId) === current)
|
|
431
|
+
admissions.delete(roomId);
|
|
432
|
+
if (!admissions.size)
|
|
433
|
+
this.pendingAdmissions.delete(runtime);
|
|
434
|
+
}).catch(() => undefined);
|
|
435
|
+
}
|
|
436
|
+
await admission;
|
|
437
|
+
try {
|
|
438
|
+
this.assertLobbyOperation(operation, runtime);
|
|
439
|
+
return await this.activateLobby(runtime, roomId, operation);
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
if (this.activeLobbyOperation === operation) {
|
|
443
|
+
await this.leaveCurrentLobby();
|
|
444
|
+
}
|
|
445
|
+
else if (this.currentRoomId !== roomId && this.desiredRoomId !== roomId) {
|
|
446
|
+
await runtime.leaveRoom(roomId);
|
|
447
|
+
}
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
async activateLobby(runtime, lobbyId, operation) {
|
|
452
|
+
this.assertLobbyOperation(operation, runtime);
|
|
170
453
|
const roomId = normalizeLobbyId(lobbyId);
|
|
171
454
|
this.currentRoomId = roomId;
|
|
172
|
-
this.
|
|
455
|
+
this.activeLobbyOperation = operation;
|
|
456
|
+
this.sessionId = crypto.randomUUID();
|
|
457
|
+
this.authorityEpoch = crypto.randomUUID();
|
|
458
|
+
this.peerAuthorityEpochs.clear();
|
|
459
|
+
this.peerHelloSequences.clear();
|
|
460
|
+
this.retiredAuthorityEpochs.clear();
|
|
461
|
+
this.authorityEpochExhausted = false;
|
|
462
|
+
this.activeHostEpoch = null;
|
|
463
|
+
const generation = ++this.matchGeneration;
|
|
173
464
|
this.startPeerPingLoop();
|
|
174
|
-
|
|
175
|
-
|
|
465
|
+
let rosterObservations = 0;
|
|
466
|
+
const stopWatching = runtime.watchRoom(roomId, (members) => {
|
|
467
|
+
if (operation === this.lobbyOperation && generation === this.matchGeneration && this.currentRoomId === roomId) {
|
|
468
|
+
rosterObservations++;
|
|
469
|
+
void this.handleRoomMembers(members);
|
|
470
|
+
}
|
|
176
471
|
});
|
|
177
|
-
|
|
178
|
-
|
|
472
|
+
if (operation !== this.lobbyOperation || generation !== this.matchGeneration || this.stopped) {
|
|
473
|
+
stopWatching();
|
|
474
|
+
throw new Error('Lobby membership retired.');
|
|
475
|
+
}
|
|
476
|
+
this.stopWatchingRoom = stopWatching;
|
|
477
|
+
const observationAtRead = rosterObservations;
|
|
478
|
+
const members = await this.getRoomMembersWithRetry(runtime, roomId, operation);
|
|
479
|
+
this.assertLobbyOperation(operation, runtime);
|
|
480
|
+
if (generation !== this.matchGeneration || this.currentRoomId !== roomId)
|
|
481
|
+
throw new Error('Lobby activation superseded.');
|
|
482
|
+
if (rosterObservations === observationAtRead)
|
|
483
|
+
await this.handleRoomMembers(members);
|
|
484
|
+
this.assertLobbyOperation(operation, runtime);
|
|
485
|
+
if (generation !== this.matchGeneration)
|
|
486
|
+
throw new Error('Lobby activation superseded.');
|
|
487
|
+
// Admission precedes route settlement. The existing peer introduction
|
|
488
|
+
// sends the current metadata after its authenticated hello, including
|
|
489
|
+
// for routes that become ready after this join returns.
|
|
179
490
|
return this.requireLobby();
|
|
180
491
|
}
|
|
181
|
-
async joinLobbyWithRetry(lobbyId,
|
|
492
|
+
async joinLobbyWithRetry(runtime, lobbyId, operation) {
|
|
182
493
|
let lastError;
|
|
183
494
|
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
495
|
+
this.assertLobbyOperation(operation, runtime);
|
|
184
496
|
try {
|
|
185
|
-
return await this.
|
|
497
|
+
return await this.joinAndActivateLobby(runtime, lobbyId, operation);
|
|
186
498
|
}
|
|
187
499
|
catch (error) {
|
|
500
|
+
this.assertLobbyOperation(operation, runtime);
|
|
188
501
|
lastError = error;
|
|
189
502
|
if (!isRoomAdmissionTransientError(error) || attempt === 5) {
|
|
190
503
|
break;
|
|
191
504
|
}
|
|
192
505
|
await delay(150 * (attempt + 1));
|
|
506
|
+
this.assertLobbyOperation(operation, runtime);
|
|
193
507
|
}
|
|
194
508
|
}
|
|
195
509
|
throw lastError;
|
|
196
510
|
}
|
|
197
|
-
async getRoomMembersWithRetry(roomId) {
|
|
198
|
-
const runtime = this.requireRuntime();
|
|
511
|
+
async getRoomMembersWithRetry(runtime, roomId, operation) {
|
|
199
512
|
let lastError;
|
|
200
513
|
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
514
|
+
this.assertLobbyOperation(operation, runtime);
|
|
201
515
|
try {
|
|
202
|
-
|
|
516
|
+
const members = await runtime.getRoomMembers(roomId);
|
|
517
|
+
this.assertLobbyOperation(operation, runtime);
|
|
518
|
+
return members;
|
|
203
519
|
}
|
|
204
520
|
catch (error) {
|
|
521
|
+
this.assertLobbyOperation(operation, runtime);
|
|
205
522
|
lastError = error;
|
|
206
523
|
if (!isRoomNotFoundError(error) || attempt === 5) {
|
|
207
524
|
break;
|
|
208
525
|
}
|
|
209
526
|
await delay(150 * (attempt + 1));
|
|
527
|
+
this.assertLobbyOperation(operation, runtime);
|
|
210
528
|
}
|
|
211
529
|
}
|
|
212
530
|
throw lastError;
|
|
213
531
|
}
|
|
214
532
|
async leaveLobby() {
|
|
533
|
+
this.beginLobbyOperation(null);
|
|
534
|
+
await this.leaveCurrentLobby();
|
|
535
|
+
}
|
|
536
|
+
leaveCurrentLobby() {
|
|
537
|
+
if (this.lobbyCleanup)
|
|
538
|
+
return this.lobbyCleanup;
|
|
215
539
|
const runtime = this.runtime;
|
|
216
540
|
const roomId = this.currentRoomId;
|
|
217
541
|
this.stopWatchingRoom?.();
|
|
218
542
|
this.stopWatchingRoom = null;
|
|
219
543
|
this.currentRoomId = null;
|
|
544
|
+
this.activeLobbyOperation = 0;
|
|
545
|
+
this.matchGeneration++;
|
|
546
|
+
this.actions.clear();
|
|
547
|
+
this.replication.close();
|
|
548
|
+
this.lastMotion.clear();
|
|
549
|
+
this.sentViews.clear();
|
|
550
|
+
this.exposedObjectIds.clear();
|
|
551
|
+
this.receivedViews.clear();
|
|
552
|
+
this.snapshotAssemblies.clear();
|
|
553
|
+
for (const queue of this.snapshotSends.values())
|
|
554
|
+
queue.close();
|
|
555
|
+
this.snapshotSends.clear();
|
|
556
|
+
this.snapshotRevisions.clear();
|
|
557
|
+
this.clock.reset();
|
|
558
|
+
this.memberIds.clear();
|
|
559
|
+
this.peerSessions.clear();
|
|
560
|
+
this.peerGenerations.clear();
|
|
561
|
+
this.memberMetadataRevisions.clear();
|
|
562
|
+
this.memberMetadataRevision = 1;
|
|
563
|
+
this.retiredSessions.clear();
|
|
564
|
+
this.peerAuthorityEpochs.clear();
|
|
565
|
+
this.peerHelloSequences.clear();
|
|
566
|
+
this.retiredAuthorityEpochs.clear();
|
|
567
|
+
this.activeHostEpoch = null;
|
|
568
|
+
this.membership = [];
|
|
569
|
+
for (const stops of this.connectionSubscriptions.values())
|
|
570
|
+
for (const stop of stops)
|
|
571
|
+
stop();
|
|
572
|
+
this.connectionSubscriptions.clear();
|
|
573
|
+
this.connectionsByPeer.clear();
|
|
574
|
+
this.connectionIds.clear();
|
|
575
|
+
for (const queue of this.latestStateQueues.values())
|
|
576
|
+
queue.close();
|
|
577
|
+
this.latestStateQueues.clear();
|
|
578
|
+
for (const reader of this.streamReaders.keys())
|
|
579
|
+
void reader.cancel('Netcode match retired').catch(() => undefined);
|
|
580
|
+
this.streamReaders.clear();
|
|
581
|
+
if (roomId)
|
|
582
|
+
this.stateStore.clear();
|
|
220
583
|
this.stopPeerPingLoop();
|
|
221
|
-
this.
|
|
222
|
-
await this.closeNetcodeChannels();
|
|
584
|
+
const channelsClosed = this.closeNetcodeChannels();
|
|
223
585
|
this.currentLobby = null;
|
|
224
586
|
this.peersById.clear();
|
|
225
587
|
this.introPeerIds.clear();
|
|
@@ -228,19 +590,33 @@ class ClientImpl {
|
|
|
228
590
|
this.seenEnvelopeOrder.length = 0;
|
|
229
591
|
this.latestStateQueues.clear();
|
|
230
592
|
this.objectStore.clear();
|
|
231
|
-
|
|
232
|
-
if (runtime && roomId) {
|
|
233
|
-
await runtime.leaveRoom(roomId);
|
|
234
|
-
}
|
|
593
|
+
const voiceStopped = this.voiceController.stop();
|
|
235
594
|
this.emitter.emit('lobby', null);
|
|
595
|
+
const closing = (async () => {
|
|
596
|
+
try {
|
|
597
|
+
await channelsClosed;
|
|
598
|
+
await voiceStopped;
|
|
599
|
+
}
|
|
600
|
+
finally {
|
|
601
|
+
if (runtime && roomId)
|
|
602
|
+
await runtime.leaveRoom(roomId);
|
|
603
|
+
}
|
|
604
|
+
})().finally(() => { if (this.lobbyCleanup === closing)
|
|
605
|
+
this.lobbyCleanup = null; });
|
|
606
|
+
this.lobbyCleanup = closing;
|
|
607
|
+
return closing;
|
|
236
608
|
}
|
|
237
609
|
async send(peerId, type, payload, options = {}) {
|
|
238
610
|
assertReliable(options);
|
|
239
|
-
|
|
611
|
+
const envelope = this.createEnvelope('user', { type, payload }, peerId);
|
|
612
|
+
await this.waitForPeer(peerId);
|
|
613
|
+
await this.sendDirectEnvelope(peerId, envelope);
|
|
240
614
|
}
|
|
241
615
|
async broadcast(type, payload, options = {}) {
|
|
242
616
|
assertReliable(options);
|
|
243
|
-
|
|
617
|
+
const envelope = this.createEnvelope('user', { type, payload });
|
|
618
|
+
const recipients = [...this.memberIds].filter((peerId) => peerId !== this.localPeerId);
|
|
619
|
+
await Promise.all(recipients.map(async (peerId) => { await this.waitForPeer(peerId); await this.sendDirectEnvelope(peerId, envelope); }));
|
|
244
620
|
}
|
|
245
621
|
on(event, handler) {
|
|
246
622
|
return this.emitter.on(event, handler);
|
|
@@ -253,23 +629,36 @@ class ClientImpl {
|
|
|
253
629
|
return () => listeners.delete(listener);
|
|
254
630
|
}
|
|
255
631
|
async setLobbyData(key, value) {
|
|
632
|
+
if (this.currentLobby?.ownerId !== this.localPeerId)
|
|
633
|
+
throw new Error('Lobby metadata requires host authority.');
|
|
256
634
|
this.lobbyMetadata = { ...this.lobbyMetadata, [key]: value };
|
|
635
|
+
this.lobbyMetadataRevision++;
|
|
257
636
|
this.refreshLobby();
|
|
258
|
-
await this.broadcastEnvelope('lobby.meta', { values: this.lobbyMetadata });
|
|
637
|
+
await this.broadcastEnvelope('lobby.meta', { values: this.lobbyMetadata, revision: this.lobbyMetadataRevision });
|
|
259
638
|
}
|
|
260
639
|
async setMemberData(key, value) {
|
|
261
640
|
this.memberMetadata = { ...this.memberMetadata, [key]: value };
|
|
641
|
+
this.memberMetadataRevision++;
|
|
262
642
|
await this.broadcastMemberMetadata();
|
|
263
643
|
}
|
|
264
644
|
async handleRoomMembers(members) {
|
|
265
645
|
if (this.stopped || !this.localPeerId || !this.currentRoomId) {
|
|
266
646
|
return;
|
|
267
647
|
}
|
|
268
|
-
const activeMembers = members
|
|
648
|
+
const activeMembers = members;
|
|
649
|
+
if (!members.some((member) => member.nodeId === this.localPeerId)) {
|
|
650
|
+
await this.leaveLobby();
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
this.membership = [...members];
|
|
654
|
+
this.memberIds.clear();
|
|
655
|
+
for (const member of members)
|
|
656
|
+
if (member.nodeId)
|
|
657
|
+
this.memberIds.add(member.nodeId);
|
|
269
658
|
const nextPeerIds = new Set();
|
|
270
659
|
for (const member of activeMembers) {
|
|
271
660
|
const peerId = member.nodeId;
|
|
272
|
-
if (!peerId || peerId === this.localPeerId) {
|
|
661
|
+
if (!peerId || peerId === this.localPeerId || !this.memberIds.has(peerId)) {
|
|
273
662
|
continue;
|
|
274
663
|
}
|
|
275
664
|
nextPeerIds.add(peerId);
|
|
@@ -290,17 +679,23 @@ class ClientImpl {
|
|
|
290
679
|
});
|
|
291
680
|
if (!previous) {
|
|
292
681
|
this.emitter.emit('peer:joined', clonePeer(peer));
|
|
293
|
-
if (this.fallbackChannel) {
|
|
294
|
-
this.queuePeerIntro(peerId);
|
|
295
|
-
}
|
|
296
682
|
}
|
|
297
683
|
}
|
|
298
684
|
for (const [peerId, peer] of Array.from(this.peersById.entries())) {
|
|
299
685
|
if (!nextPeerIds.has(peerId)) {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
686
|
+
this.retirePeerSession(peerId);
|
|
687
|
+
this.replication.closePeer(peerId);
|
|
688
|
+
this.sentViews.delete(peerId);
|
|
689
|
+
this.receivedViews.delete(peerId);
|
|
303
690
|
this.peersById.delete(peerId);
|
|
691
|
+
const connection = this.connectionsByPeer.get(peerId);
|
|
692
|
+
if (connection) {
|
|
693
|
+
for (const stop of this.connectionSubscriptions.get(connection) ?? [])
|
|
694
|
+
stop();
|
|
695
|
+
this.connectionSubscriptions.delete(connection);
|
|
696
|
+
if (connection.id)
|
|
697
|
+
this.connectionIds.delete(connection.id);
|
|
698
|
+
}
|
|
304
699
|
this.connectionsByPeer.delete(peerId);
|
|
305
700
|
this.introPeerIds.delete(peerId);
|
|
306
701
|
this.peerStatsById.delete(peerId);
|
|
@@ -309,6 +704,11 @@ class ClientImpl {
|
|
|
309
704
|
}
|
|
310
705
|
}
|
|
311
706
|
this.refreshLobby(activeMembers);
|
|
707
|
+
for (const connection of this.runtime?.getConnections() ?? []) {
|
|
708
|
+
const id = connection.remoteNodeId ?? connection.deviceId;
|
|
709
|
+
if (id && nextPeerIds.has(id))
|
|
710
|
+
this.attachConnection(connection);
|
|
711
|
+
}
|
|
312
712
|
void this.reconcileMesh();
|
|
313
713
|
}
|
|
314
714
|
async reconcileMesh() {
|
|
@@ -320,17 +720,27 @@ class ClientImpl {
|
|
|
320
720
|
return;
|
|
321
721
|
}
|
|
322
722
|
attachConnection(connection, peerIdHint) {
|
|
723
|
+
if (!this.currentRoomId)
|
|
724
|
+
return;
|
|
323
725
|
const connectionId = connection.id;
|
|
324
726
|
if (connectionId && this.connectionIds.has(connectionId)) {
|
|
325
727
|
return;
|
|
326
728
|
}
|
|
327
729
|
const peerId = peerIdHint ?? connection.remoteNodeId ?? connection.deviceId;
|
|
328
|
-
if (!peerId || peerId === this.localPeerId) {
|
|
730
|
+
if (!peerId || peerId === this.localPeerId || !this.memberIds.has(peerId)) {
|
|
329
731
|
return;
|
|
330
732
|
}
|
|
331
733
|
if (connectionId) {
|
|
332
734
|
this.connectionIds.add(connectionId);
|
|
333
735
|
}
|
|
736
|
+
const previousConnection = this.connectionsByPeer.get(peerId);
|
|
737
|
+
if (previousConnection && previousConnection !== connection) {
|
|
738
|
+
for (const stop of this.connectionSubscriptions.get(previousConnection) ?? [])
|
|
739
|
+
stop();
|
|
740
|
+
this.connectionSubscriptions.delete(previousConnection);
|
|
741
|
+
if (previousConnection.id)
|
|
742
|
+
this.connectionIds.delete(previousConnection.id);
|
|
743
|
+
}
|
|
334
744
|
this.connectionsByPeer.set(peerId, connection);
|
|
335
745
|
const payloadReady = this.isPeerPayloadReady(peerId);
|
|
336
746
|
const peer = this.upsertPeer(peerId, { connected: payloadReady });
|
|
@@ -342,10 +752,19 @@ class ClientImpl {
|
|
|
342
752
|
this.emitter.emit('peer:connected', clonePeer(peer));
|
|
343
753
|
this.startVoicePeer(peerId);
|
|
344
754
|
}
|
|
345
|
-
|
|
346
|
-
|
|
755
|
+
let active = true;
|
|
756
|
+
const stops = [() => { active = false; }];
|
|
757
|
+
const generation = this.matchGeneration;
|
|
758
|
+
const stopMessages = connection.onMessage((message) => {
|
|
759
|
+
if (active && generation === this.matchGeneration && this.connectionsByPeer.get(peerId) === connection) {
|
|
760
|
+
this.handleConnectionMessage(peerId, message);
|
|
761
|
+
}
|
|
347
762
|
});
|
|
348
|
-
|
|
763
|
+
if (stopMessages)
|
|
764
|
+
stops.push(stopMessages);
|
|
765
|
+
const stopDisconnect = connection.onDisconnect(() => {
|
|
766
|
+
if (!active || generation !== this.matchGeneration)
|
|
767
|
+
return;
|
|
349
768
|
const wasCurrentConnection = this.connectionsByPeer.get(peerId) === connection;
|
|
350
769
|
if (wasCurrentConnection) {
|
|
351
770
|
this.connectionsByPeer.delete(peerId);
|
|
@@ -364,11 +783,14 @@ class ClientImpl {
|
|
|
364
783
|
this.emitter.emit('peer:disconnected', clonePeer(disconnected));
|
|
365
784
|
this.voiceController.handlePeerDisconnected(peerId);
|
|
366
785
|
});
|
|
786
|
+
if (stopDisconnect)
|
|
787
|
+
stops.push(stopDisconnect);
|
|
788
|
+
this.connectionSubscriptions.set(connection, stops);
|
|
367
789
|
if (payloadReady) {
|
|
368
790
|
this.queuePeerIntro(peerId, true);
|
|
369
791
|
}
|
|
370
792
|
if (payloadReady && this.currentRoomId) {
|
|
371
|
-
void this.sendEnvelope(peerId, 'ping', {});
|
|
793
|
+
void this.sendEnvelope(peerId, 'ping', {}).catch(() => undefined);
|
|
372
794
|
}
|
|
373
795
|
}
|
|
374
796
|
startVoicePeer(peerId) {
|
|
@@ -391,11 +813,15 @@ class ClientImpl {
|
|
|
391
813
|
await this.sendEnvelope(peerId, 'hello', {
|
|
392
814
|
peerId: this.localPeerId,
|
|
393
815
|
displayName: this.options.displayName,
|
|
816
|
+
version: PROTOCOL_VERSION,
|
|
817
|
+
authorityEpoch: this.authorityEpoch,
|
|
394
818
|
});
|
|
819
|
+
await this.waitForPeer(peerId);
|
|
395
820
|
await this.sendStateSnapshot(peerId);
|
|
396
821
|
await this.sendObjectSnapshot(peerId);
|
|
397
|
-
|
|
398
|
-
|
|
822
|
+
if (this.currentLobby?.ownerId === this.localPeerId)
|
|
823
|
+
await this.sendEnvelope(peerId, 'lobby.meta', { values: this.lobbyMetadata, revision: this.lobbyMetadataRevision });
|
|
824
|
+
await this.sendEnvelope(peerId, 'member.meta', { values: this.memberMetadata, revision: this.memberMetadataRevision });
|
|
399
825
|
}
|
|
400
826
|
catch (error) {
|
|
401
827
|
this.introPeerIds.delete(peerId);
|
|
@@ -403,35 +829,94 @@ class ClientImpl {
|
|
|
403
829
|
}
|
|
404
830
|
}
|
|
405
831
|
queuePeerIntro(peerId, force = false) {
|
|
832
|
+
const generation = this.matchGeneration;
|
|
406
833
|
queueTask(() => {
|
|
834
|
+
if (generation !== this.matchGeneration)
|
|
835
|
+
return;
|
|
407
836
|
void this.sendPeerIntro(peerId, force).catch((error) => {
|
|
408
|
-
if (this.currentRoomId) {
|
|
837
|
+
if (this.currentRoomId && generation === this.matchGeneration && this.memberIds.has(peerId)) {
|
|
409
838
|
console.warn('[openrtc-netcode] peer intro failed', error);
|
|
410
839
|
}
|
|
411
840
|
});
|
|
412
841
|
});
|
|
413
842
|
}
|
|
414
843
|
handleConnectionMessage(peerId, message) {
|
|
844
|
+
if (!this.currentRoomId || !this.memberIds.has(peerId))
|
|
845
|
+
return;
|
|
846
|
+
if (typeof message === 'string' && textEncoder.encode(message).byteLength > MAX_FRAME_BYTES)
|
|
847
|
+
return;
|
|
848
|
+
if (!boundedJson(typeof message === 'string' ? safeJson(message) : message, 25000, 28))
|
|
849
|
+
return;
|
|
415
850
|
const envelope = decodeEnvelope(message);
|
|
416
|
-
if (!envelope
|
|
851
|
+
if (!envelope) {
|
|
852
|
+
const candidate = typeof message === 'string' ? safeJson(message) : message;
|
|
853
|
+
if (isRecord(candidate) && candidate.protocol === NETCODE_PROTOCOL && candidate.v !== PROTOCOL_VERSION) {
|
|
854
|
+
this.emitter.emit('protocol:error', { peerId, reason: 'incompatible-version' });
|
|
855
|
+
}
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (envelope.lobbyId !== this.currentRoomId || envelope.from !== peerId) {
|
|
417
859
|
return;
|
|
418
860
|
}
|
|
419
861
|
if (envelope.from === this.localPeerId) {
|
|
420
862
|
return;
|
|
421
863
|
}
|
|
864
|
+
if ((envelope.targetSessionId && envelope.targetSessionId !== this.sessionId)
|
|
865
|
+
|| (envelope.kind === 'action' && envelope.targetSessionId !== this.sessionId))
|
|
866
|
+
return;
|
|
867
|
+
if (envelope.kind === 'action' && envelope.targetAuthorityEpoch !== this.authorityEpoch)
|
|
868
|
+
return;
|
|
422
869
|
if (envelope.target && envelope.target !== this.localPeerId) {
|
|
423
870
|
return;
|
|
424
871
|
}
|
|
872
|
+
if (envelope.kind === 'hello') {
|
|
873
|
+
if (!isRecord(envelope.body) || envelope.body.version !== PROTOCOL_VERSION) {
|
|
874
|
+
this.emitter.emit('protocol:error', { peerId, reason: 'incompatible-version' });
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const epoch = envelope.body.authorityEpoch;
|
|
878
|
+
if (typeof epoch !== 'string' || !epoch.length || epoch.length > 128
|
|
879
|
+
|| this.retiredAuthorityEpochs.has(JSON.stringify([peerId, epoch])))
|
|
880
|
+
return;
|
|
881
|
+
const lastHello = this.peerHelloSequences.get(peerId);
|
|
882
|
+
if (lastHello?.sessionId === envelope.sessionId && envelope.seq <= lastHello.seq)
|
|
883
|
+
return;
|
|
884
|
+
const previousEpoch = this.peerAuthorityEpochs.get(peerId);
|
|
885
|
+
if (!this.acceptPeerSession(peerId, envelope.sessionId))
|
|
886
|
+
return;
|
|
887
|
+
this.peerHelloSequences.set(peerId, { sessionId: envelope.sessionId, seq: envelope.seq });
|
|
888
|
+
this.peerAuthorityEpochs.set(peerId, epoch);
|
|
889
|
+
if (peerId === this.currentLobby?.ownerId && !this.authorityEpochExhausted
|
|
890
|
+
&& (this.activeHostEpoch?.epoch !== epoch || this.activeHostEpoch.sessionId !== envelope.sessionId)) {
|
|
891
|
+
if (previousEpoch && previousEpoch !== epoch)
|
|
892
|
+
this.resetHostState(peerId);
|
|
893
|
+
this.receivedViews.delete(peerId);
|
|
894
|
+
this.snapshotAssemblies.delete(peerId);
|
|
895
|
+
this.activeHostEpoch = { peerId, sessionId: envelope.sessionId, epoch, floor: envelope.seq };
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
else if (this.peerSessions.get(peerId) !== envelope.sessionId) {
|
|
899
|
+
// Every application state is preceded by an authenticated hello.
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const hostState = (this.options.authority?.mode === 'host' || !!this.options.authority?.hostOwnedKinds?.length)
|
|
903
|
+
&& (envelope.kind.startsWith('state.') || envelope.kind.startsWith('object.'));
|
|
904
|
+
if (envelope.from === this.currentLobby?.ownerId && (hostState || envelope.kind === 'lobby.meta')
|
|
905
|
+
&& (this.authorityEpochExhausted || !this.activeHostEpoch
|
|
906
|
+
|| this.activeHostEpoch.sessionId !== envelope.sessionId || envelope.seq <= this.activeHostEpoch.floor))
|
|
907
|
+
return;
|
|
425
908
|
if (!this.markEnvelopeSeen(envelope)) {
|
|
426
909
|
return;
|
|
427
910
|
}
|
|
428
|
-
|
|
429
|
-
this.updatePeerStats(peerId, {
|
|
430
|
-
connected: true,
|
|
431
|
-
transport: this.resolvePeerTransport(peerId),
|
|
432
|
-
});
|
|
911
|
+
freezeJson(envelope);
|
|
433
912
|
this.emitter.emit('envelope', envelope);
|
|
434
913
|
switch (envelope.kind) {
|
|
914
|
+
case 'action':
|
|
915
|
+
void this.actions.receive(peerId, envelope.sessionId, envelope.body).catch(() => undefined);
|
|
916
|
+
break;
|
|
917
|
+
case 'action.receipt':
|
|
918
|
+
this.actions.receiveReceipt(peerId, envelope.body);
|
|
919
|
+
break;
|
|
435
920
|
case 'hello':
|
|
436
921
|
this.handleHello(peerId, envelope.body);
|
|
437
922
|
break;
|
|
@@ -439,10 +924,10 @@ class ClientImpl {
|
|
|
439
924
|
this.handleUserMessage(envelope);
|
|
440
925
|
break;
|
|
441
926
|
case 'state.snapshot':
|
|
442
|
-
this.handleStateSnapshot(envelope.body);
|
|
927
|
+
this.handleStateSnapshot(peerId, envelope.body);
|
|
443
928
|
break;
|
|
444
929
|
case 'state.patch':
|
|
445
|
-
this.handleStatePatch(envelope.body);
|
|
930
|
+
this.handleStatePatch(peerId, envelope.body);
|
|
446
931
|
break;
|
|
447
932
|
case 'object.snapshot':
|
|
448
933
|
this.handleObjectSnapshot(envelope.from, envelope.body);
|
|
@@ -450,17 +935,20 @@ class ClientImpl {
|
|
|
450
935
|
case 'object.upsert':
|
|
451
936
|
this.handleObjectUpsert(envelope.from, envelope.body);
|
|
452
937
|
break;
|
|
938
|
+
case 'object.batch':
|
|
939
|
+
this.handleObjectBatch(peerId, envelope.body);
|
|
940
|
+
break;
|
|
453
941
|
case 'object.remove':
|
|
454
942
|
this.handleObjectRemove(envelope.from, envelope.body);
|
|
455
943
|
break;
|
|
456
944
|
case 'lobby.meta':
|
|
457
|
-
this.handleLobbyMetadata(envelope.body);
|
|
945
|
+
this.handleLobbyMetadata(peerId, envelope.body);
|
|
458
946
|
break;
|
|
459
947
|
case 'member.meta':
|
|
460
948
|
this.handleMemberMetadata(peerId, envelope.body);
|
|
461
949
|
break;
|
|
462
950
|
case 'ping':
|
|
463
|
-
void this.sendEnvelope(peerId, 'pong', { pingSentAt: envelope.sentAt });
|
|
951
|
+
void this.sendEnvelope(peerId, 'pong', { pingSentAt: envelope.sentAt }).catch(() => undefined);
|
|
464
952
|
break;
|
|
465
953
|
case 'peer':
|
|
466
954
|
break;
|
|
@@ -469,8 +957,66 @@ class ClientImpl {
|
|
|
469
957
|
break;
|
|
470
958
|
}
|
|
471
959
|
}
|
|
960
|
+
retirePeerSession(peerId) {
|
|
961
|
+
this.peerGenerations.set(peerId, (this.peerGenerations.get(peerId) ?? 0) + 1);
|
|
962
|
+
for (const channels of [this.channelsByPeer, this.motionChannelsByPeer]) {
|
|
963
|
+
const channel = channels.get(peerId);
|
|
964
|
+
channels.delete(peerId);
|
|
965
|
+
if (channel)
|
|
966
|
+
void channel.close().catch(() => undefined);
|
|
967
|
+
}
|
|
968
|
+
this.pendingChannelsByPeer.delete(peerId);
|
|
969
|
+
this.pendingMotionChannelsByPeer.delete(peerId);
|
|
970
|
+
for (const [reader, owner] of this.streamReaders)
|
|
971
|
+
if (owner === peerId) {
|
|
972
|
+
void reader.cancel('Netcode peer retired.').catch(() => undefined);
|
|
973
|
+
this.streamReaders.delete(reader);
|
|
974
|
+
}
|
|
975
|
+
const previous = this.peerSessions.get(peerId);
|
|
976
|
+
if (previous)
|
|
977
|
+
this.retiredSessions.add(JSON.stringify([peerId, previous]));
|
|
978
|
+
this.peerSessions.delete(peerId);
|
|
979
|
+
this.peerAuthorityEpochs.delete(peerId);
|
|
980
|
+
this.peerHelloSequences.delete(peerId);
|
|
981
|
+
if (this.activeHostEpoch?.peerId === peerId)
|
|
982
|
+
this.activeHostEpoch = null;
|
|
983
|
+
this.actions.removePeer(peerId);
|
|
984
|
+
for (const [key, queue] of this.latestStateQueues) {
|
|
985
|
+
if (JSON.parse(key)[0] !== peerId)
|
|
986
|
+
continue;
|
|
987
|
+
queue.close(new Error('Netcode recipient session retired.'));
|
|
988
|
+
this.latestStateQueues.delete(key);
|
|
989
|
+
}
|
|
990
|
+
this.lastMotion.delete(peerId);
|
|
991
|
+
this.memberMetadataRevisions.delete(peerId);
|
|
992
|
+
this.stateStore.removeOwner(peerId);
|
|
993
|
+
this.objectStore.removeOwner(peerId);
|
|
994
|
+
this.receivedViews.delete(peerId);
|
|
995
|
+
this.snapshotAssemblies.delete(peerId);
|
|
996
|
+
this.sentViews.delete(peerId);
|
|
997
|
+
this.exposedObjectIds.delete(peerId);
|
|
998
|
+
this.snapshotSends.get(peerId)?.close();
|
|
999
|
+
this.snapshotSends.delete(peerId);
|
|
1000
|
+
this.snapshotRevisions.delete(peerId);
|
|
1001
|
+
this.introPeerIds.delete(peerId);
|
|
1002
|
+
this.replication.closePeer(peerId);
|
|
1003
|
+
}
|
|
1004
|
+
acceptPeerSession(peerId, sessionId) {
|
|
1005
|
+
if (this.retiredSessions.has(JSON.stringify([peerId, sessionId])))
|
|
1006
|
+
return false;
|
|
1007
|
+
const previous = this.peerSessions.get(peerId);
|
|
1008
|
+
if (previous && previous !== sessionId) {
|
|
1009
|
+
if (this.retiredSessions.size >= 4096) {
|
|
1010
|
+
this.emitter.emit('protocol:error', { peerId, reason: 'session-capacity' });
|
|
1011
|
+
return false;
|
|
1012
|
+
}
|
|
1013
|
+
this.retirePeerSession(peerId);
|
|
1014
|
+
}
|
|
1015
|
+
this.peerSessions.set(peerId, sessionId);
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
472
1018
|
markEnvelopeSeen(envelope) {
|
|
473
|
-
const id =
|
|
1019
|
+
const id = JSON.stringify([envelope.from, envelope.sessionId, envelope.seq]);
|
|
474
1020
|
if (this.seenEnvelopeIds.has(id)) {
|
|
475
1021
|
return false;
|
|
476
1022
|
}
|
|
@@ -488,6 +1034,10 @@ class ClientImpl {
|
|
|
488
1034
|
if (!isRecord(body)) {
|
|
489
1035
|
return;
|
|
490
1036
|
}
|
|
1037
|
+
if (body.version !== PROTOCOL_VERSION) {
|
|
1038
|
+
this.emitter.emit('protocol:error', { peerId, reason: 'incompatible-version' });
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
491
1041
|
const displayName = typeof body.displayName === 'string' ? body.displayName : undefined;
|
|
492
1042
|
this.upsertPeer(peerId, {
|
|
493
1043
|
connected: true,
|
|
@@ -512,18 +1062,24 @@ class ClientImpl {
|
|
|
512
1062
|
listener(message);
|
|
513
1063
|
}
|
|
514
1064
|
}
|
|
515
|
-
handleStateSnapshot(body) {
|
|
516
|
-
if (
|
|
1065
|
+
handleStateSnapshot(from, body) {
|
|
1066
|
+
if (this.options.authority?.mode === 'host' && from !== this.currentLobby?.ownerId)
|
|
1067
|
+
return;
|
|
1068
|
+
if (!isRecord(body) || !isRecord(body.values) || !isRecord(body.owners) || !isRecord(body.revisions)) {
|
|
517
1069
|
return;
|
|
518
1070
|
}
|
|
519
1071
|
const snapshot = {
|
|
520
1072
|
values: body.values,
|
|
521
1073
|
owners: stringifyRecord(body.owners),
|
|
1074
|
+
revisions: body.revisions,
|
|
522
1075
|
};
|
|
523
|
-
this.stateStore.applySnapshot(snapshot.values, snapshot.owners);
|
|
524
|
-
|
|
1076
|
+
const accepted = this.stateStore.applySnapshot(snapshot.values, snapshot.owners, snapshot.revisions, from);
|
|
1077
|
+
if (Object.keys(accepted.values).length)
|
|
1078
|
+
this.emitter.emit('state:snapshot', accepted);
|
|
525
1079
|
}
|
|
526
|
-
handleStatePatch(body) {
|
|
1080
|
+
handleStatePatch(from, body) {
|
|
1081
|
+
if (this.options.authority?.mode === 'host' && from !== this.currentLobby?.ownerId)
|
|
1082
|
+
return;
|
|
527
1083
|
if (!isRecord(body) || typeof body.key !== 'string' || typeof body.owner !== 'string') {
|
|
528
1084
|
return;
|
|
529
1085
|
}
|
|
@@ -531,45 +1087,121 @@ class ClientImpl {
|
|
|
531
1087
|
key: body.key,
|
|
532
1088
|
value: body.value,
|
|
533
1089
|
owner: body.owner,
|
|
1090
|
+
revision: body.revision,
|
|
534
1091
|
};
|
|
535
|
-
this.stateStore.applyPatch(patch.key, patch.value, patch.owner)
|
|
536
|
-
|
|
1092
|
+
if (this.stateStore.applyPatch(patch.key, patch.value, patch.owner, patch.revision, from)) {
|
|
1093
|
+
this.emitter.emit('state:patch', patch);
|
|
1094
|
+
}
|
|
537
1095
|
}
|
|
538
1096
|
handleObjectSnapshot(from, body) {
|
|
539
|
-
if (
|
|
1097
|
+
if (this.options.authority?.mode === 'host' && from !== this.currentLobby?.ownerId)
|
|
1098
|
+
return;
|
|
1099
|
+
if (!isRecord(body) || !isRecord(body.objects))
|
|
540
1100
|
return;
|
|
1101
|
+
let assembly = this.snapshotAssemblies.get(from);
|
|
1102
|
+
if (!assembly) {
|
|
1103
|
+
assembly = new SnapshotAssembly();
|
|
1104
|
+
this.snapshotAssemblies.set(from, assembly);
|
|
541
1105
|
}
|
|
542
|
-
const
|
|
1106
|
+
const complete = assembly.push(body);
|
|
1107
|
+
if (!complete)
|
|
1108
|
+
return;
|
|
1109
|
+
body = complete;
|
|
1110
|
+
if (!isRecord(body) || !isRecord(body.objects))
|
|
1111
|
+
return;
|
|
1112
|
+
const objects = Object.create(null);
|
|
543
1113
|
for (const [id, object] of Object.entries(body.objects)) {
|
|
544
|
-
if (isNetcodeObjectState(object)) {
|
|
1114
|
+
if (isNetcodeObjectState(object) && object.incarnation === this.objectIncarnation(this.peerSessions.get(from), this.peerAuthorityEpochs.get(from), object.kind) && object.ownerId === from && (!this.requiresHost(object.kind) || from === this.currentLobby?.ownerId)) {
|
|
545
1115
|
objects[id] = object;
|
|
546
1116
|
}
|
|
547
1117
|
}
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
1118
|
+
const removed = Object.create(null);
|
|
1119
|
+
if (isRecord(body.removed)) {
|
|
1120
|
+
for (const [id, removal] of Object.entries(body.removed)) {
|
|
1121
|
+
if (isObjectRemoval(removal))
|
|
1122
|
+
removed[id] = removal;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
const revision = body.viewRevision;
|
|
1126
|
+
const previous = this.receivedViews.get(from);
|
|
1127
|
+
if (typeof revision !== 'number' || !Number.isSafeInteger(revision) || revision < 1 || (previous && previous.revision >= revision))
|
|
1128
|
+
return;
|
|
1129
|
+
const ids = new Set(Object.keys(objects));
|
|
1130
|
+
this.receivedViews.set(from, { revision, ids, definitions: Object.values(objects) });
|
|
1131
|
+
this.objectStore.pruneOwner(from, ids);
|
|
1132
|
+
const snapshot = this.objectStore.applySnapshot(objects, from, removed);
|
|
1133
|
+
if (Object.keys(snapshot.objects).length || Object.keys(snapshot.removed).length) {
|
|
1134
|
+
this.emitter.emit('object:snapshot', snapshot);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
handleObjectBatch(from, body) {
|
|
1138
|
+
if (!isRecord(body))
|
|
1139
|
+
return;
|
|
1140
|
+
const view = this.receivedViews.get(from);
|
|
1141
|
+
if (!view || body.viewRevision !== view.revision)
|
|
1142
|
+
return;
|
|
1143
|
+
let objects;
|
|
1144
|
+
if (typeof body.packed === 'string') {
|
|
1145
|
+
const definitions = view.definitions;
|
|
1146
|
+
try {
|
|
1147
|
+
objects = decodeMotion(motionFromBase64(body.packed), definitions);
|
|
1148
|
+
}
|
|
1149
|
+
catch {
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
else if (Array.isArray(body.motion)) {
|
|
1154
|
+
try {
|
|
1155
|
+
objects = decodeJsonMotion(body.motion, view.definitions);
|
|
1156
|
+
}
|
|
1157
|
+
catch {
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
else {
|
|
1162
|
+
if (!Array.isArray(body.objects) || body.objects.length > 1024)
|
|
1163
|
+
return;
|
|
1164
|
+
objects = body.objects;
|
|
1165
|
+
}
|
|
1166
|
+
for (const object of objects)
|
|
1167
|
+
this.handleObjectUpsert(from, { object });
|
|
551
1168
|
}
|
|
552
1169
|
handleObjectUpsert(from, body) {
|
|
553
1170
|
if (!isRecord(body) || !isNetcodeObjectState(body.object)) {
|
|
554
1171
|
return;
|
|
555
1172
|
}
|
|
1173
|
+
if (body.object.incarnation !== this.objectIncarnation(this.peerSessions.get(from), this.peerAuthorityEpochs.get(from), body.object.kind) || !this.receivedViews.get(from)?.ids.has(body.object.id))
|
|
1174
|
+
return;
|
|
1175
|
+
if (this.requiresHost(body.object.kind) && from !== this.currentLobby?.ownerId)
|
|
1176
|
+
return;
|
|
556
1177
|
const upsert = { object: body.object };
|
|
557
|
-
this.objectStore.applyUpsert(body.object, from)
|
|
558
|
-
|
|
1178
|
+
if (this.objectStore.applyUpsert(body.object, from)) {
|
|
1179
|
+
this.emitter.emit('object:upsert', upsert);
|
|
1180
|
+
}
|
|
559
1181
|
}
|
|
560
1182
|
handleObjectRemove(from, body) {
|
|
561
|
-
if (!
|
|
1183
|
+
if (!isObjectRemoval(body)) {
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
if (this.options.authority?.mode === 'host' && from !== this.currentLobby?.ownerId)
|
|
562
1187
|
return;
|
|
1188
|
+
if (this.objectStore.applyRemove(body.id, from, body)) {
|
|
1189
|
+
this.emitter.emit('object:remove', body);
|
|
563
1190
|
}
|
|
564
|
-
const remove = { id: body.id };
|
|
565
|
-
this.objectStore.applyRemove(remove.id, from);
|
|
566
|
-
this.emitter.emit('object:remove', remove);
|
|
567
1191
|
}
|
|
568
|
-
handleLobbyMetadata(body) {
|
|
1192
|
+
handleLobbyMetadata(from, body) {
|
|
1193
|
+
if (from !== this.currentLobby?.ownerId)
|
|
1194
|
+
return;
|
|
569
1195
|
if (!isRecord(body) || !isRecord(body.values)) {
|
|
570
1196
|
return;
|
|
571
1197
|
}
|
|
1198
|
+
const revision = body.revision;
|
|
1199
|
+
if (typeof revision !== 'number' || !Number.isSafeInteger(revision) || revision < 1
|
|
1200
|
+
|| (this.lobbyMetadataOwner === from && revision <= this.lobbyMetadataRevision))
|
|
1201
|
+
return;
|
|
572
1202
|
const metadata = { values: stringifyRecord(body.values) };
|
|
1203
|
+
this.lobbyMetadataOwner = from;
|
|
1204
|
+
this.lobbyMetadataRevision = revision;
|
|
573
1205
|
this.lobbyMetadata = metadata.values;
|
|
574
1206
|
this.refreshLobby();
|
|
575
1207
|
this.emitter.emit('lobby:metadata', metadata);
|
|
@@ -578,7 +1210,14 @@ class ClientImpl {
|
|
|
578
1210
|
if (!isRecord(body) || !isRecord(body.values)) {
|
|
579
1211
|
return;
|
|
580
1212
|
}
|
|
1213
|
+
const revision = body.revision;
|
|
1214
|
+
if (typeof revision !== 'number' || !Number.isSafeInteger(revision) || revision < 1 || revision <= (this.memberMetadataRevisions.get(peerId) ?? 0))
|
|
1215
|
+
return;
|
|
1216
|
+
this.memberMetadataRevisions.set(peerId, revision);
|
|
581
1217
|
const metadata = { values: stringifyRecord(body.values) };
|
|
1218
|
+
const peer = this.peersById.get(peerId);
|
|
1219
|
+
if (peer)
|
|
1220
|
+
peer.metadata = {};
|
|
582
1221
|
this.upsertPeer(peerId, { metadata: metadata.values });
|
|
583
1222
|
this.emitter.emit('member:metadata', { peerId, values: metadata.values });
|
|
584
1223
|
}
|
|
@@ -594,18 +1233,82 @@ class ClientImpl {
|
|
|
594
1233
|
});
|
|
595
1234
|
}
|
|
596
1235
|
async sendStateSnapshot(peerId) {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
1236
|
+
const generation = this.matchGeneration, authorityEpoch = this.authorityEpoch;
|
|
1237
|
+
const values = this.stateStore.snapshot(), owners = this.stateStore.owners(), revisions = this.stateStore.revisions();
|
|
1238
|
+
let page = { values: Object.create(null), owners: Object.create(null), revisions: Object.create(null) };
|
|
1239
|
+
let size = 0, count = 0;
|
|
1240
|
+
const send = async () => {
|
|
1241
|
+
if (generation !== this.matchGeneration || authorityEpoch !== this.authorityEpoch)
|
|
1242
|
+
throw new Error('State snapshot match retired.');
|
|
1243
|
+
await this.sendEnvelope(peerId, 'state.snapshot', page);
|
|
1244
|
+
page = { values: Object.create(null), owners: Object.create(null), revisions: Object.create(null) };
|
|
1245
|
+
size = count = 0;
|
|
1246
|
+
};
|
|
1247
|
+
for (const [key, value] of Object.entries(values)) {
|
|
1248
|
+
if (owners[key] !== this.localPeerId || revisions[key] < 1)
|
|
1249
|
+
continue;
|
|
1250
|
+
const bytes = textEncoder.encode(JSON.stringify([key, value, owners[key], revisions[key]])).byteLength;
|
|
1251
|
+
if (count && (size + bytes > 24 * 1024 || count >= 128))
|
|
1252
|
+
await send();
|
|
1253
|
+
page.values[key] = value;
|
|
1254
|
+
page.owners[key] = owners[key];
|
|
1255
|
+
page.revisions[key] = revisions[key];
|
|
1256
|
+
size += bytes;
|
|
1257
|
+
count++;
|
|
1258
|
+
}
|
|
1259
|
+
if (count)
|
|
1260
|
+
await send();
|
|
1261
|
+
}
|
|
1262
|
+
sendObjectSnapshot(peerId) {
|
|
1263
|
+
let queue = this.snapshotSends.get(peerId);
|
|
1264
|
+
if (!queue) {
|
|
1265
|
+
const generation = this.matchGeneration, session = this.peerSessions.get(peerId), authorityEpoch = this.authorityEpoch;
|
|
1266
|
+
queue = new LatestStateQueue(async () => {
|
|
1267
|
+
if (generation !== this.matchGeneration || session !== this.peerSessions.get(peerId) || authorityEpoch !== this.authorityEpoch)
|
|
1268
|
+
throw new Error('Snapshot match retired.');
|
|
1269
|
+
const objects = Object.fromEntries(Object.entries(this.objectStore.snapshot())
|
|
1270
|
+
.filter(([, object]) => object.ownerId === this.localPeerId && (this.options.interest?.(peerId, object) ?? true)));
|
|
1271
|
+
const revision = (this.snapshotRevisions.get(peerId) ?? 0) + 1;
|
|
1272
|
+
this.snapshotRevisions.set(peerId, revision);
|
|
1273
|
+
let exposed = this.exposedObjectIds.get(peerId);
|
|
1274
|
+
if (!exposed) {
|
|
1275
|
+
exposed = new Set();
|
|
1276
|
+
this.exposedObjectIds.set(peerId, exposed);
|
|
1277
|
+
}
|
|
1278
|
+
// Record before the first await: deletion may race page delivery.
|
|
1279
|
+
for (const id of Object.keys(objects))
|
|
1280
|
+
exposed.add(id);
|
|
1281
|
+
const pages = snapshotPages({ objects, viewRevision: revision,
|
|
1282
|
+
removed: Object.fromEntries(Object.entries(this.objectStore.removalSnapshot())
|
|
1283
|
+
.filter(([id, removal]) => removal.ownerId === this.localPeerId && exposed.has(id))),
|
|
1284
|
+
});
|
|
1285
|
+
for (const page of pages) {
|
|
1286
|
+
if (generation !== this.matchGeneration || session !== this.peerSessions.get(peerId) || authorityEpoch !== this.authorityEpoch)
|
|
1287
|
+
throw new Error('Snapshot match retired.');
|
|
1288
|
+
await this.sendEnvelope(peerId, 'object.snapshot', page);
|
|
1289
|
+
}
|
|
1290
|
+
if (generation !== this.matchGeneration || session !== this.peerSessions.get(peerId) || authorityEpoch !== this.authorityEpoch)
|
|
1291
|
+
throw new Error('Snapshot match retired.');
|
|
1292
|
+
this.sentViews.set(peerId, { revision, ids: new Set(Object.keys(objects)),
|
|
1293
|
+
signatures: new Map(Object.values(objects).map((object) => [object.id, this.objectSignature(object)])),
|
|
1294
|
+
});
|
|
1295
|
+
});
|
|
1296
|
+
this.snapshotSends.set(peerId, queue);
|
|
1297
|
+
}
|
|
1298
|
+
return queue.push(undefined);
|
|
601
1299
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
1300
|
+
isLocalMatchActive() {
|
|
1301
|
+
return !!this.currentRoomId && !!this.localPeerId && this.memberIds.has(this.localPeerId)
|
|
1302
|
+
&& (this.runtime?.isRoomActive?.(this.currentRoomId) ?? true);
|
|
1303
|
+
}
|
|
1304
|
+
requiresHost(kind) {
|
|
1305
|
+
return this.options.authority?.mode === 'host' || !!(kind && this.options.authority?.hostOwnedKinds?.includes(kind));
|
|
1306
|
+
}
|
|
1307
|
+
objectSignature(object) {
|
|
1308
|
+
return canonicalJson([object.kind, object.metadata ?? {}, object.transform.scale ?? null]);
|
|
606
1309
|
}
|
|
607
1310
|
async broadcastMemberMetadata() {
|
|
608
|
-
await this.broadcastEnvelope('member.meta', { values: this.memberMetadata });
|
|
1311
|
+
await this.broadcastEnvelope('member.meta', { values: this.memberMetadata, revision: this.memberMetadataRevision });
|
|
609
1312
|
}
|
|
610
1313
|
startPeerPingLoop() {
|
|
611
1314
|
this.stopPeerPingLoop();
|
|
@@ -616,7 +1319,7 @@ class ClientImpl {
|
|
|
616
1319
|
for (const peer of this.peersById.values()) {
|
|
617
1320
|
const payloadReady = this.refreshPeerPayloadState(peer.id);
|
|
618
1321
|
if (payloadReady) {
|
|
619
|
-
void this.sendEnvelope(peer.id, 'ping', {});
|
|
1322
|
+
void this.sendEnvelope(peer.id, 'ping', {}).catch(() => undefined);
|
|
620
1323
|
}
|
|
621
1324
|
}
|
|
622
1325
|
}, PING_INTERVAL_MS);
|
|
@@ -644,90 +1347,53 @@ class ClientImpl {
|
|
|
644
1347
|
}
|
|
645
1348
|
async broadcastEnvelope(kind, body) {
|
|
646
1349
|
const envelope = this.createEnvelope(kind, body);
|
|
647
|
-
|
|
648
|
-
this.
|
|
649
|
-
}
|
|
650
|
-
const sends = Array.from(this.connectionsByPeer.keys()).map((peerId) => this.sendDirectEnvelope(peerId, envelope));
|
|
1350
|
+
const sends = Array.from(this.memberIds).filter((peerId) => peerId !== this.localPeerId)
|
|
1351
|
+
.map((peerId) => this.sendDirectEnvelope(peerId, envelope));
|
|
651
1352
|
await Promise.all(sends);
|
|
652
1353
|
}
|
|
653
1354
|
broadcastLatestState(key, kind, body) {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
this.latestStateQueues.
|
|
658
|
-
|
|
659
|
-
|
|
1355
|
+
const envelope = this.createEnvelope(kind, body);
|
|
1356
|
+
return Promise.all([...this.memberIds].filter((peerId) => peerId !== this.localPeerId).map((peerId) => {
|
|
1357
|
+
const queueKey = JSON.stringify([peerId, key]);
|
|
1358
|
+
let queue = this.latestStateQueues.get(queueKey);
|
|
1359
|
+
if (!queue) {
|
|
1360
|
+
queue = new LatestStateQueue((state) => this.sendDirectEnvelope(peerId, state));
|
|
1361
|
+
this.latestStateQueues.set(queueKey, queue);
|
|
1362
|
+
}
|
|
1363
|
+
return queue.push(envelope);
|
|
1364
|
+
})).then(() => undefined);
|
|
660
1365
|
}
|
|
661
1366
|
async sendEnvelope(peerId, kind, body) {
|
|
662
1367
|
const envelope = this.createEnvelope(kind, body, peerId);
|
|
663
|
-
if (this.fallbackChannel) {
|
|
664
|
-
this.fallbackChannel.postMessage(encodeEnvelope(envelope));
|
|
665
|
-
}
|
|
666
1368
|
await this.sendDirectEnvelope(peerId, envelope);
|
|
667
1369
|
}
|
|
668
|
-
async sendDirectEnvelope(peerId, envelope) {
|
|
669
|
-
|
|
1370
|
+
async sendDirectEnvelope(peerId, envelope, motion = false) {
|
|
1371
|
+
if (!this.isLocalMatchActive() || !this.memberIds.has(peerId) || envelope.lobbyId !== this.currentRoomId || envelope.sessionId !== this.sessionId) {
|
|
1372
|
+
throw new Error('Netcode recipient is outside the current match.');
|
|
1373
|
+
}
|
|
1374
|
+
const generation = this.matchGeneration;
|
|
1375
|
+
const channel = await this.resolveNetcodeChannel(peerId, motion);
|
|
1376
|
+
if (generation !== this.matchGeneration || !this.memberIds.has(peerId) || !this.isLocalMatchActive())
|
|
1377
|
+
throw new Error('Netcode match retired.');
|
|
670
1378
|
if (channel) {
|
|
671
|
-
await channel.send(textEncoder.encode(
|
|
1379
|
+
await channel.send(textEncoder.encode(this.encodedEnvelope(envelope)));
|
|
672
1380
|
return;
|
|
673
1381
|
}
|
|
674
1382
|
const connection = this.resolvePayloadConnection(peerId);
|
|
675
1383
|
if (!connection || connection.isClosed) {
|
|
676
|
-
|
|
1384
|
+
throw new Error('Netcode recipient has no application-ready route.');
|
|
677
1385
|
}
|
|
678
|
-
|
|
679
|
-
try {
|
|
680
|
-
send = Promise.resolve(connection.send(encodeEnvelope(envelope)));
|
|
681
|
-
}
|
|
682
|
-
catch (error) {
|
|
683
|
-
if (this.fallbackChannel) {
|
|
684
|
-
this.warnDirectSendFailure(peerId, envelope.kind, error);
|
|
685
|
-
return;
|
|
686
|
-
}
|
|
687
|
-
throw error;
|
|
688
|
-
}
|
|
689
|
-
if (this.fallbackChannel) {
|
|
690
|
-
void send.catch((error) => this.warnDirectSendFailure(peerId, envelope.kind, error));
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
await send;
|
|
694
|
-
}
|
|
695
|
-
warnDirectSendFailure(peerId, kind, error) {
|
|
696
|
-
const key = `${peerId}:${kind}`;
|
|
697
|
-
if (this.directSendWarnings.has(key)) {
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
this.directSendWarnings.add(key);
|
|
701
|
-
console.warn('[openrtc-netcode] direct send failed; local dev fallback already published envelope', error);
|
|
702
|
-
}
|
|
703
|
-
openFallbackChannel(roomId) {
|
|
704
|
-
this.closeFallbackChannel();
|
|
705
|
-
if (this.options.localFallback !== true) {
|
|
706
|
-
return;
|
|
707
|
-
}
|
|
708
|
-
if (typeof globalThis.BroadcastChannel !== 'function') {
|
|
709
|
-
return;
|
|
710
|
-
}
|
|
711
|
-
const channel = new globalThis.BroadcastChannel(`openrtc-netcode:${roomId}`);
|
|
712
|
-
channel.onmessage = (event) => {
|
|
713
|
-
const envelope = decodeEnvelope(event.data);
|
|
714
|
-
if (!envelope || envelope.from === this.localPeerId) {
|
|
715
|
-
return;
|
|
716
|
-
}
|
|
717
|
-
this.handleConnectionMessage(envelope.from, envelope);
|
|
718
|
-
};
|
|
719
|
-
this.fallbackChannel = channel;
|
|
1386
|
+
await connection.send(this.encodedEnvelope(envelope));
|
|
720
1387
|
}
|
|
721
|
-
|
|
722
|
-
this.
|
|
723
|
-
this.
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
const existing = this.channelsByPeer.get(peerId);
|
|
1388
|
+
async resolveNetcodeChannel(peerId, motion = false) {
|
|
1389
|
+
const channels = motion ? this.motionChannelsByPeer : this.channelsByPeer;
|
|
1390
|
+
const pendingChannels = motion ? this.pendingMotionChannelsByPeer : this.pendingChannelsByPeer;
|
|
1391
|
+
const channelId = motion ? MOTION_CHANNEL_ID : NETCODE_CHANNEL_ID;
|
|
1392
|
+
const existing = channels.get(peerId);
|
|
727
1393
|
if (existing) {
|
|
728
1394
|
return existing;
|
|
729
1395
|
}
|
|
730
|
-
const pending =
|
|
1396
|
+
const pending = pendingChannels.get(peerId);
|
|
731
1397
|
if (pending) {
|
|
732
1398
|
return pending;
|
|
733
1399
|
}
|
|
@@ -735,57 +1401,41 @@ class ClientImpl {
|
|
|
735
1401
|
if (typeof runtime?.connectScopedChannel !== 'function') {
|
|
736
1402
|
return null;
|
|
737
1403
|
}
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1404
|
+
const generation = this.matchGeneration;
|
|
1405
|
+
const peerGeneration = this.peerGenerations.get(peerId) ?? 0;
|
|
1406
|
+
const opening = Promise.resolve().then(() => {
|
|
1407
|
+
if (generation !== this.matchGeneration)
|
|
1408
|
+
return null;
|
|
1409
|
+
return runtime.connectScopedChannel({
|
|
1410
|
+
peerId, scope: channelId, channelId, existingRouteOnly: true,
|
|
1411
|
+
timeoutMs: CHANNEL_OPEN_TIMEOUT_MS, framing: 'u32be',
|
|
1412
|
+
maxFrameBytes: MAX_FRAME_BYTES, frameTimeoutMs: 10000,
|
|
1413
|
+
});
|
|
1414
|
+
}).then(async (channel) => {
|
|
1415
|
+
if (!channel)
|
|
1416
|
+
return null;
|
|
1417
|
+
if (generation !== this.matchGeneration || peerGeneration !== (this.peerGenerations.get(peerId) ?? 0) || !this.memberIds.has(peerId)) {
|
|
1418
|
+
await channel.close();
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
this.attachNetcodeChannel(peerId, channel, motion);
|
|
751
1422
|
return channel;
|
|
752
1423
|
}).catch(() => null).finally(() => {
|
|
753
|
-
|
|
1424
|
+
if (pendingChannels.get(peerId) === opening)
|
|
1425
|
+
pendingChannels.delete(peerId);
|
|
754
1426
|
});
|
|
755
|
-
|
|
1427
|
+
pendingChannels.set(peerId, opening);
|
|
756
1428
|
return opening;
|
|
757
1429
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
return existing;
|
|
762
|
-
}
|
|
763
|
-
if (!this.currentRoomId || !this.runtime) {
|
|
764
|
-
return undefined;
|
|
765
|
-
}
|
|
766
|
-
const members = await this.getRoomMembersWithRetry(this.currentRoomId).catch(() => []);
|
|
767
|
-
const member = members.find((candidate) => candidate.nodeId === peerId && candidate.ticket);
|
|
768
|
-
if (!member?.ticket) {
|
|
769
|
-
return undefined;
|
|
770
|
-
}
|
|
771
|
-
const previous = this.peersById.get(peerId);
|
|
772
|
-
this.peersById.set(peerId, {
|
|
773
|
-
id: peerId,
|
|
774
|
-
userId: member.userId ?? previous?.userId,
|
|
775
|
-
ticket: member.ticket,
|
|
776
|
-
joinedAt: member.joinedAt ?? previous?.joinedAt,
|
|
777
|
-
connected: previous?.connected ?? this.isPeerPayloadReady(peerId),
|
|
778
|
-
metadata: previous?.metadata ?? parseMetadata(member.metadata),
|
|
779
|
-
stats: previous?.stats ?? this.peerStatsById.get(peerId),
|
|
780
|
-
});
|
|
781
|
-
this.refreshLobby();
|
|
782
|
-
return member.ticket;
|
|
783
|
-
}
|
|
784
|
-
attachNetcodeChannel(peerId, channel) {
|
|
785
|
-
this.channelsByPeer.set(peerId, channel);
|
|
1430
|
+
attachNetcodeChannel(peerId, channel, motion = false) {
|
|
1431
|
+
(motion ? this.motionChannelsByPeer : this.channelsByPeer).set(peerId, channel);
|
|
1432
|
+
const generation = this.matchGeneration;
|
|
786
1433
|
channel.onMessage((payload) => {
|
|
787
|
-
|
|
1434
|
+
if (generation === this.matchGeneration)
|
|
1435
|
+
this.handleConnectionMessage(peerId, textDecoder.decode(payload));
|
|
788
1436
|
});
|
|
1437
|
+
if (motion)
|
|
1438
|
+
return;
|
|
789
1439
|
const peer = this.upsertPeer(peerId, { connected: true });
|
|
790
1440
|
this.updatePeerStats(peerId, {
|
|
791
1441
|
connected: true,
|
|
@@ -801,56 +1451,96 @@ class ClientImpl {
|
|
|
801
1451
|
return;
|
|
802
1452
|
}
|
|
803
1453
|
this.stopIncomingNetcodeChannel?.();
|
|
804
|
-
|
|
1454
|
+
const stops = [NETCODE_CHANNEL_ID, MOTION_CHANNEL_ID].map((channelId) => runtime.onChannelStream(channelId, (incoming) => {
|
|
805
1455
|
if (incoming.type !== 'bi' || !incoming.remoteNodeId || !incoming.stream) {
|
|
806
1456
|
return false;
|
|
807
1457
|
}
|
|
808
1458
|
this.readIncomingNetcodeStream(incoming.remoteNodeId, incoming.stream);
|
|
809
1459
|
return true;
|
|
810
|
-
});
|
|
1460
|
+
}));
|
|
1461
|
+
this.stopIncomingNetcodeChannel = () => { for (const stop of stops)
|
|
1462
|
+
stop(); };
|
|
811
1463
|
}
|
|
812
1464
|
readIncomingNetcodeStream(peerId, stream) {
|
|
813
1465
|
const readable = stream.recv ?? stream.readable;
|
|
814
|
-
if (!readable) {
|
|
1466
|
+
if (!readable || !this.memberIds.has(peerId) || this.streamReaders.size >= 32) {
|
|
1467
|
+
void readable?.cancel('Netcode stream admission rejected.').catch(() => undefined);
|
|
1468
|
+
void (stream.send ?? stream.writable)?.abort('Netcode stream admission rejected.').catch(() => undefined);
|
|
815
1469
|
return;
|
|
816
1470
|
}
|
|
1471
|
+
const generation = this.matchGeneration;
|
|
1472
|
+
const reader = readable.getReader();
|
|
1473
|
+
this.streamReaders.set(reader, peerId);
|
|
817
1474
|
void (async () => {
|
|
818
|
-
const
|
|
819
|
-
let
|
|
1475
|
+
const decoder = new FrameDecoder();
|
|
1476
|
+
let incompleteSince = performance.now();
|
|
820
1477
|
try {
|
|
821
|
-
while (!this.stopped) {
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1478
|
+
while (!this.stopped && generation === this.matchGeneration) {
|
|
1479
|
+
let timer;
|
|
1480
|
+
let result;
|
|
1481
|
+
try {
|
|
1482
|
+
if (decoder.incomplete) {
|
|
1483
|
+
const remaining = Math.max(1, 15000 - (performance.now() - incompleteSince));
|
|
1484
|
+
const timeout = new Promise((_, reject) => {
|
|
1485
|
+
timer = setTimeout(() => reject(new Error('Netcode frame read timed out.')), remaining);
|
|
1486
|
+
});
|
|
1487
|
+
result = await Promise.race([reader.read(), timeout]);
|
|
1488
|
+
}
|
|
1489
|
+
else
|
|
1490
|
+
result = await reader.read();
|
|
825
1491
|
}
|
|
826
|
-
|
|
827
|
-
|
|
1492
|
+
finally {
|
|
1493
|
+
clearTimeout(timer);
|
|
828
1494
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
break;
|
|
834
|
-
}
|
|
835
|
-
const payload = buffered.slice(4, 4 + length);
|
|
836
|
-
buffered = buffered.slice(4 + length);
|
|
837
|
-
this.handleConnectionMessage(peerId, textDecoder.decode(payload));
|
|
1495
|
+
if (result.done) {
|
|
1496
|
+
if (decoder.incomplete)
|
|
1497
|
+
throw new Error('Truncated netcode frame.');
|
|
1498
|
+
break;
|
|
838
1499
|
}
|
|
1500
|
+
if (generation !== this.matchGeneration)
|
|
1501
|
+
break;
|
|
1502
|
+
const wasIncomplete = decoder.incomplete;
|
|
1503
|
+
let completed = false;
|
|
1504
|
+
decoder.feed(result.value, (payload) => { completed = true; this.handleConnectionMessage(peerId, textDecoder.decode(payload)); });
|
|
1505
|
+
if (!wasIncomplete || completed)
|
|
1506
|
+
incompleteSince = performance.now();
|
|
1507
|
+
if (!decoder.incomplete)
|
|
1508
|
+
incompleteSince = performance.now();
|
|
839
1509
|
}
|
|
840
1510
|
}
|
|
1511
|
+
catch (error) {
|
|
1512
|
+
if (generation === this.matchGeneration)
|
|
1513
|
+
this.emitter.emit('protocol:error', {
|
|
1514
|
+
peerId, reason: error instanceof Error ? error.message : String(error),
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
841
1517
|
finally {
|
|
1518
|
+
await reader.cancel('Netcode reader retired').catch(() => undefined);
|
|
842
1519
|
reader.releaseLock();
|
|
1520
|
+
this.streamReaders.delete(reader);
|
|
843
1521
|
}
|
|
844
|
-
})();
|
|
1522
|
+
})().catch(() => undefined);
|
|
845
1523
|
}
|
|
846
1524
|
async closeNetcodeChannels() {
|
|
847
|
-
const channels =
|
|
1525
|
+
const channels = [...this.channelsByPeer.values(), ...this.motionChannelsByPeer.values()];
|
|
1526
|
+
this.motionChannelsByPeer.clear();
|
|
1527
|
+
this.pendingMotionChannelsByPeer.clear();
|
|
848
1528
|
this.channelsByPeer.clear();
|
|
849
1529
|
this.pendingChannelsByPeer.clear();
|
|
850
1530
|
await Promise.allSettled(channels.map((channel) => channel.close()));
|
|
851
1531
|
}
|
|
1532
|
+
encodedEnvelope(envelope) {
|
|
1533
|
+
let encoded = this.encodedEnvelopes.get(envelope);
|
|
1534
|
+
if (!encoded) {
|
|
1535
|
+
encoded = encodeEnvelope(envelope);
|
|
1536
|
+
if (!boundedJson(envelope, 25000, 28) || textEncoder.encode(encoded).byteLength > MAX_FRAME_BYTES)
|
|
1537
|
+
throw new Error('Netcode frame exceeds byte limit.');
|
|
1538
|
+
this.encodedEnvelopes.set(envelope, encoded);
|
|
1539
|
+
}
|
|
1540
|
+
return encoded;
|
|
1541
|
+
}
|
|
852
1542
|
createEnvelope(kind, body, target) {
|
|
853
|
-
if (!this.currentRoomId || !this.localPeerId) {
|
|
1543
|
+
if (!this.isLocalMatchActive() || !this.currentRoomId || !this.localPeerId) {
|
|
854
1544
|
throw new Error('openrtc-netcode requires a joined lobby before sending.');
|
|
855
1545
|
}
|
|
856
1546
|
return {
|
|
@@ -861,11 +1551,17 @@ class ClientImpl {
|
|
|
861
1551
|
from: this.localPeerId,
|
|
862
1552
|
seq: ++this.seq,
|
|
863
1553
|
sentAt: Date.now(),
|
|
1554
|
+
sessionId: this.sessionId,
|
|
864
1555
|
target,
|
|
865
|
-
|
|
1556
|
+
targetSessionId: target && kind !== 'hello' ? this.peerSessions.get(target) : undefined,
|
|
1557
|
+
targetAuthorityEpoch: target && kind === 'action' ? this.peerAuthorityEpochs.get(target) : undefined,
|
|
1558
|
+
body: freezeJson(cloneJson(body, MAX_FRAME_BYTES - 2048, 25000, 28)),
|
|
866
1559
|
};
|
|
867
1560
|
}
|
|
868
1561
|
resolveStateOwner(options) {
|
|
1562
|
+
if (this.options.authority?.mode === 'host') {
|
|
1563
|
+
return this.currentLobby?.ownerId ?? this.options.authority.hostId ?? this.localPeerId ?? 'local';
|
|
1564
|
+
}
|
|
869
1565
|
if (options?.owner === 'lobby-owner') {
|
|
870
1566
|
return this.currentLobby?.ownerId ?? this.localPeerId ?? 'local';
|
|
871
1567
|
}
|
|
@@ -951,11 +1647,81 @@ class ClientImpl {
|
|
|
951
1647
|
allowFallback: true,
|
|
952
1648
|
};
|
|
953
1649
|
}
|
|
1650
|
+
objectIncarnation(sessionId, epoch, kind) {
|
|
1651
|
+
return this.requiresHost(kind) ? (sessionId && epoch ? `${sessionId}:${epoch}` : undefined) : sessionId;
|
|
1652
|
+
}
|
|
1653
|
+
resetHostState(peerId) {
|
|
1654
|
+
if (this.options.authority?.mode === 'host') {
|
|
1655
|
+
this.stateStore.removeOwner(peerId);
|
|
1656
|
+
this.objectStore.removeOwner(peerId);
|
|
1657
|
+
}
|
|
1658
|
+
else if (this.options.authority?.hostOwnedKinds?.length) {
|
|
1659
|
+
this.objectStore.removeOwner(peerId, new Set(this.options.authority.hostOwnedKinds));
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
954
1662
|
refreshLobby(members) {
|
|
955
1663
|
if (!this.currentRoomId || !this.localPeerId) {
|
|
956
1664
|
return;
|
|
957
1665
|
}
|
|
958
|
-
const ownerId = resolveOwnerId(members, this.localPeerId);
|
|
1666
|
+
const ownerId = this.options.authority?.hostId ?? resolveOwnerId(members ?? this.membership, this.localPeerId);
|
|
1667
|
+
const previousHost = this.currentLobby?.ownerId;
|
|
1668
|
+
if (!previousHost)
|
|
1669
|
+
this.stateStore.bindInitialHost(ownerId, this.localPeerId);
|
|
1670
|
+
if (previousHost && previousHost !== ownerId) {
|
|
1671
|
+
const previousEpoch = previousHost === this.localPeerId ? this.authorityEpoch : this.peerAuthorityEpochs.get(previousHost);
|
|
1672
|
+
if (previousEpoch) {
|
|
1673
|
+
if (this.retiredAuthorityEpochs.size >= 4096) {
|
|
1674
|
+
this.authorityEpochExhausted = true;
|
|
1675
|
+
this.emitter.emit('protocol:error', { peerId: previousHost, reason: 'authority-capacity' });
|
|
1676
|
+
}
|
|
1677
|
+
else
|
|
1678
|
+
this.retiredAuthorityEpochs.add(JSON.stringify([previousHost, previousEpoch]));
|
|
1679
|
+
}
|
|
1680
|
+
// The authenticated hello and membership projections can arrive in
|
|
1681
|
+
// either order. Reuse an already observed, non-retired host period.
|
|
1682
|
+
const hostHello = this.peerHelloSequences.get(ownerId);
|
|
1683
|
+
const hostEpoch = this.peerAuthorityEpochs.get(ownerId);
|
|
1684
|
+
this.activeHostEpoch = hostHello && hostEpoch
|
|
1685
|
+
&& !this.retiredAuthorityEpochs.has(JSON.stringify([ownerId, hostEpoch]))
|
|
1686
|
+
? { peerId: ownerId, sessionId: hostHello.sessionId, epoch: hostEpoch, floor: hostHello.seq }
|
|
1687
|
+
: null;
|
|
1688
|
+
this.resetHostState(previousHost);
|
|
1689
|
+
this.actions.removePeer(previousHost, { preserveReceipts: true });
|
|
1690
|
+
if (previousHost === this.localPeerId || ownerId === this.localPeerId) {
|
|
1691
|
+
// The match/session and physical routes remain owned by their
|
|
1692
|
+
// existing owners. Only this sender's game-authority period changes.
|
|
1693
|
+
this.authorityEpoch = crypto.randomUUID();
|
|
1694
|
+
this.actions.clear({ preserveOutgoing: true });
|
|
1695
|
+
this.replication.close();
|
|
1696
|
+
this.lastMotion.clear();
|
|
1697
|
+
for (const queue of this.latestStateQueues.values())
|
|
1698
|
+
queue.close(new Error('Host authority reset.'));
|
|
1699
|
+
this.latestStateQueues.clear();
|
|
1700
|
+
for (const queue of this.snapshotSends.values())
|
|
1701
|
+
queue.close();
|
|
1702
|
+
this.snapshotSends.clear();
|
|
1703
|
+
this.sentViews.clear();
|
|
1704
|
+
for (const exposed of this.exposedObjectIds.values()) {
|
|
1705
|
+
for (const id of exposed)
|
|
1706
|
+
if (!this.objectStore.read(id))
|
|
1707
|
+
exposed.delete(id);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
for (const peerId of this.memberIds)
|
|
1711
|
+
if (peerId !== this.localPeerId)
|
|
1712
|
+
this.queuePeerIntro(peerId, true);
|
|
1713
|
+
for (const [peerId, view] of this.sentViews) {
|
|
1714
|
+
for (const id of [...view.ids])
|
|
1715
|
+
if (!this.objectStore.read(id)) {
|
|
1716
|
+
this.replication.cancel(peerId, id);
|
|
1717
|
+
this.lastMotion.get(peerId)?.delete(id);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
if (this.lobbyMetadataOwner !== ownerId) {
|
|
1722
|
+
this.lobbyMetadataOwner = ownerId;
|
|
1723
|
+
this.lobbyMetadataRevision = this.localPeerId === ownerId ? Math.max(1, this.lobbyMetadataRevision) : 0;
|
|
1724
|
+
}
|
|
959
1725
|
this.currentLobby = {
|
|
960
1726
|
id: this.currentRoomId,
|
|
961
1727
|
localPeerId: this.localPeerId,
|
|
@@ -986,15 +1752,17 @@ class ClientImpl {
|
|
|
986
1752
|
}
|
|
987
1753
|
registerNetcodeChannel() {
|
|
988
1754
|
const maybeChannelClient = this.runtime;
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1755
|
+
for (const [id, delivery] of [[NETCODE_CHANNEL_ID, 'reliable'], [MOTION_CHANNEL_ID, 'latest-state']])
|
|
1756
|
+
maybeChannelClient.channels?.register({
|
|
1757
|
+
id,
|
|
1758
|
+
kind: 'sync',
|
|
1759
|
+
ownership: 'shared',
|
|
1760
|
+
peerModel: 'node-first',
|
|
1761
|
+
routing: 'stream-envelope',
|
|
1762
|
+
readiness: 'settled-peer',
|
|
1763
|
+
delivery,
|
|
1764
|
+
description: 'OpenRTC netcode V2 reliable control, actions and state snapshots.',
|
|
1765
|
+
});
|
|
998
1766
|
}
|
|
999
1767
|
}
|
|
1000
1768
|
export function createNetcode(options = {}) {
|
|
@@ -1003,20 +1771,11 @@ export function createNetcode(options = {}) {
|
|
|
1003
1771
|
function normalizeLobbyId(lobbyId) {
|
|
1004
1772
|
return lobbyId.trim().toUpperCase();
|
|
1005
1773
|
}
|
|
1006
|
-
function hasActiveMember(member) {
|
|
1007
|
-
const now = Date.now();
|
|
1008
|
-
if (typeof member.expiresAt === 'number') {
|
|
1009
|
-
return member.expiresAt >= now;
|
|
1010
|
-
}
|
|
1011
|
-
const lastSeenAt = member.lastSeenAt ?? member.joinedAt ?? now;
|
|
1012
|
-
return now - lastSeenAt <= 5 * 60 * 1000;
|
|
1013
|
-
}
|
|
1014
1774
|
function resolveOwnerId(members, fallback) {
|
|
1015
|
-
const candidates = (members ?? [])
|
|
1775
|
+
const candidates = [...(members ?? []), { nodeId: fallback }]
|
|
1016
1776
|
.filter((member) => typeof member.nodeId === 'string')
|
|
1017
1777
|
.sort((left, right) => {
|
|
1018
|
-
|
|
1019
|
-
return joinedDelta || left.nodeId.localeCompare(right.nodeId);
|
|
1778
|
+
return left.nodeId < right.nodeId ? -1 : left.nodeId > right.nodeId ? 1 : 0;
|
|
1020
1779
|
});
|
|
1021
1780
|
return candidates[0]?.nodeId ?? fallback;
|
|
1022
1781
|
}
|
|
@@ -1064,18 +1823,6 @@ function cloneLobby(lobby) {
|
|
|
1064
1823
|
members: lobby.members.map(clonePeer),
|
|
1065
1824
|
};
|
|
1066
1825
|
}
|
|
1067
|
-
function concatBytes(left, right) {
|
|
1068
|
-
const combined = new Uint8Array(left.byteLength + right.byteLength);
|
|
1069
|
-
combined.set(left, 0);
|
|
1070
|
-
combined.set(right, left.byteLength);
|
|
1071
|
-
return combined;
|
|
1072
|
-
}
|
|
1073
|
-
function readU32BE(bytes) {
|
|
1074
|
-
return (((bytes[0] ?? 0) * 0x1000000)
|
|
1075
|
-
+ (((bytes[1] ?? 0) << 16) >>> 0)
|
|
1076
|
-
+ (((bytes[2] ?? 0) << 8) >>> 0)
|
|
1077
|
-
+ (bytes[3] ?? 0)) >>> 0;
|
|
1078
|
-
}
|
|
1079
1826
|
function assertReliable(options) {
|
|
1080
1827
|
if (options.reliability && options.reliability !== 'reliable') {
|
|
1081
1828
|
throw new Error('openrtc-netcode V1 supports reliable messages only.');
|
|
@@ -1111,3 +1858,11 @@ function queueTask(callback) {
|
|
|
1111
1858
|
}
|
|
1112
1859
|
void Promise.resolve().then(callback);
|
|
1113
1860
|
}
|
|
1861
|
+
function safeJson(value) {
|
|
1862
|
+
try {
|
|
1863
|
+
return JSON.parse(value);
|
|
1864
|
+
}
|
|
1865
|
+
catch {
|
|
1866
|
+
return undefined;
|
|
1867
|
+
}
|
|
1868
|
+
}
|