openrtc-netcode 3.0.0 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js 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/v1';
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.broadcastLatestState(`object:${object.id}`, 'object.upsert', { object }), (id) => this.broadcastEnvelope('object.remove', { id }));
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.started) {
75
- return;
254
+ if (this.stopping) {
255
+ await this.stopping;
256
+ return this.start();
76
257
  }
77
- this.started = true;
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
- for (const connection of this.runtime.getConnections()) {
93
- this.attachConnection(connection);
94
- }
290
+ this.started = true;
95
291
  }
96
- async stop() {
292
+ stop() {
293
+ this.lobbyOperation++;
294
+ this.desiredRoomId = null;
295
+ if (this.stopping)
296
+ return this.stopping;
97
297
  this.stopped = true;
98
- await this.leaveLobby();
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.emitter.clear();
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
- await this.start();
126
- const runtime = this.requireRuntime();
127
- await this.leaveLobby();
128
- const lobbyId = await runtime.createRoom(options.id);
129
- this.lobbyMetadata = { ...(options.metadata ?? {}) };
130
- // Creating a lobby does not itself imply that an injected runtime has
131
- // joined its membership scope. Join through the lifecycle owner before
132
- // observing the room so it can establish and project peer routes.
133
- await runtime.joinRoom(lobbyId, { bootstrapPeers: false });
134
- return this.activateLobby(lobbyId);
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
- this.memberMetadata = { ...(options.metadata ?? {}) };
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
- return await this.joinLobby(lobbyId, options);
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
- if (!isRoomNotFoundError(error)) {
151
- throw error;
152
- }
153
- const runtime = this.requireRuntime();
154
- const roomId = normalizeLobbyId(lobbyId);
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
- return this.activateLobby(createdRoomId);
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
- if (!isRoomAlreadyExistsError(createError)) {
390
+ this.assertLobbyOperation(operation, runtime);
391
+ if (!isRoomAlreadyExistsError(createError))
162
392
  throw createError;
163
- }
164
393
  }
165
- return this.joinLobbyWithRetry(roomId, options);
394
+ return await this.joinLobbyWithRetry(runtime, roomId, operation);
395
+ }
396
+ catch (error) {
397
+ this.clearFailedLobbyIntent(operation);
398
+ throw error;
166
399
  }
167
400
  }
168
- async activateLobby(lobbyId) {
169
- const runtime = this.requireRuntime();
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.openFallbackChannel(roomId);
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
- this.stopWatchingRoom = runtime.watchRoom(roomId, (members) => {
175
- void this.handleRoomMembers(members);
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
- await this.handleRoomMembers(await this.getRoomMembersWithRetry(roomId));
178
- await this.broadcastMemberMetadata();
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, options) {
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.joinLobby(lobbyId, options);
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
- return await runtime.getRoomMembers(roomId);
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.closeFallbackChannel();
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
- await this.voiceController.stop();
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
- await this.sendEnvelope(peerId, 'user', { type, payload });
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
- await this.broadcastEnvelope('user', { type, payload });
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.filter(hasActiveMember);
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
- if (this.fallbackChannel) {
301
- continue;
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
- connection.onMessage((message) => {
346
- this.handleConnectionMessage(peerId, message);
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
- connection.onDisconnect(() => {
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
- await this.sendEnvelope(peerId, 'lobby.meta', { values: this.lobbyMetadata });
398
- await this.sendEnvelope(peerId, 'member.meta', { values: this.memberMetadata });
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 || envelope.lobbyId !== this.currentRoomId) {
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
- this.upsertPeer(peerId, { connected: true });
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 = `${envelope.from}:${envelope.seq}`;
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 (!isRecord(body) || !isRecord(body.values) || !isRecord(body.owners)) {
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
- this.emitter.emit('state:snapshot', snapshot);
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
- this.emitter.emit('state:patch', patch);
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 (!isRecord(body) || !isRecord(body.objects)) {
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 objects = {};
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 snapshot = { objects };
549
- this.objectStore.applySnapshot(objects, from);
550
- this.emitter.emit('object:snapshot', snapshot);
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
- this.emitter.emit('object:upsert', upsert);
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 (!isRecord(body) || typeof body.id !== 'string') {
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
- await this.sendEnvelope(peerId, 'state.snapshot', {
598
- values: this.stateStore.snapshot(),
599
- owners: this.stateStore.owners(),
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
- async sendObjectSnapshot(peerId) {
603
- await this.sendEnvelope(peerId, 'object.snapshot', {
604
- objects: this.objectStore.snapshot(),
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
- if (this.fallbackChannel) {
648
- this.fallbackChannel.postMessage(encodeEnvelope(envelope));
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
- let queue = this.latestStateQueues.get(key);
655
- if (!queue) {
656
- queue = new LatestStateQueue((state) => this.broadcastEnvelope(state.kind, state.body));
657
- this.latestStateQueues.set(key, queue);
658
- }
659
- return queue.push({ kind, body });
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
- const channel = await this.resolveNetcodeChannel(peerId);
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(encodeEnvelope(envelope)));
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
- return;
1384
+ throw new Error('Netcode recipient has no application-ready route.');
677
1385
  }
678
- let send;
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
- closeFallbackChannel() {
722
- this.fallbackChannel?.close();
723
- this.fallbackChannel = null;
724
- }
725
- async resolveNetcodeChannel(peerId) {
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 = this.pendingChannelsByPeer.get(peerId);
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 ticket = await this.resolvePeerTicket(peerId);
739
- if (!ticket) {
740
- return null;
741
- }
742
- const opening = runtime.connectScopedChannel({
743
- peerId,
744
- ticket,
745
- scope: NETCODE_CHANNEL_ID,
746
- channelId: NETCODE_CHANNEL_ID,
747
- timeoutMs: CHANNEL_OPEN_TIMEOUT_MS,
748
- framing: 'u32be',
749
- }).then((channel) => {
750
- this.attachNetcodeChannel(peerId, channel);
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
- this.pendingChannelsByPeer.delete(peerId);
1424
+ if (pendingChannels.get(peerId) === opening)
1425
+ pendingChannels.delete(peerId);
754
1426
  });
755
- this.pendingChannelsByPeer.set(peerId, opening);
1427
+ pendingChannels.set(peerId, opening);
756
1428
  return opening;
757
1429
  }
758
- async resolvePeerTicket(peerId) {
759
- const existing = this.peersById.get(peerId)?.ticket?.trim();
760
- if (existing) {
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
- this.handleConnectionMessage(peerId, textDecoder.decode(payload));
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
- this.stopIncomingNetcodeChannel = runtime.onChannelStream(NETCODE_CHANNEL_ID, (incoming) => {
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 reader = readable.getReader();
819
- let buffered = new Uint8Array(0);
1475
+ const decoder = new FrameDecoder();
1476
+ let incompleteSince = performance.now();
820
1477
  try {
821
- while (!this.stopped) {
822
- const { value, done } = await reader.read();
823
- if (done) {
824
- break;
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
- if (!value?.byteLength) {
827
- continue;
1492
+ finally {
1493
+ clearTimeout(timer);
828
1494
  }
829
- buffered = concatBytes(buffered, value);
830
- while (buffered.byteLength >= 4) {
831
- const length = readU32BE(buffered);
832
- if (buffered.byteLength < length + 4) {
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 = Array.from(this.channelsByPeer.values());
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
- body,
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
- maybeChannelClient.channels?.register({
990
- id: NETCODE_CHANNEL_ID,
991
- kind: 'sync',
992
- ownership: 'shared',
993
- peerModel: 'node-first',
994
- routing: 'stream-envelope',
995
- readiness: 'settled-peer',
996
- description: 'OpenRTC netcode V1 messages and state snapshots.',
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
- const joinedDelta = (left.joinedAt ?? 0) - (right.joinedAt ?? 0);
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
+ }