openrtc-netcode 3.0.1 → 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/objects.js CHANGED
@@ -1,11 +1,27 @@
1
+ import { boundedJson, cloneJson, freezeJson, isId, jsonByteLength } from './validation.js';
2
+ // Reserve page/envelope overhead below the 8 MiB snapshot assembly budget.
3
+ const MAX_STORE_BYTES = 7 * 1024 * 1024;
4
+ const MAX_OBJECT_BYTES = 220 * 1024;
1
5
  export class ObjectStore {
2
- constructor(localPeerId, publishUpsert, publishRemove) {
6
+ constructor(localPeerId, publishUpsert, publishRemove, capacity = 10000, incarnation = () => this.instanceId, tick = () => 0, canWrite = () => true) {
3
7
  this.localPeerId = localPeerId;
4
8
  this.publishUpsert = publishUpsert;
5
9
  this.publishRemove = publishRemove;
10
+ this.capacity = capacity;
11
+ this.incarnation = incarnation;
12
+ this.tick = tick;
13
+ this.canWrite = canWrite;
6
14
  this.objectsById = new Map();
15
+ this.removedById = new Map();
7
16
  this.listeners = new Set();
8
17
  this.nextObjectId = 0;
18
+ this.instanceId = crypto.randomUUID();
19
+ this.structureListeners = new Set();
20
+ this.idSnapshots = new Map();
21
+ this.entryBytes = new Map();
22
+ this.bytes = 0;
23
+ this.snapshotBatch = false;
24
+ this.structurePending = false;
9
25
  }
10
26
  list() {
11
27
  return Array.from(this.objectsById.values()).map(cloneObject);
@@ -14,8 +30,54 @@ export class ObjectStore {
14
30
  const object = this.objectsById.get(id);
15
31
  return object ? cloneObject(object) : undefined;
16
32
  }
33
+ /** Immutable, allocation-free read for render loops. */
34
+ read(id) {
35
+ return this.objectsById.get(id);
36
+ }
37
+ ids(kind) {
38
+ const key = kind ?? '';
39
+ let snapshot = this.idSnapshots.get(key);
40
+ if (!snapshot) {
41
+ snapshot = Object.freeze(Array.from(this.objectsById.values())
42
+ .filter((object) => !kind || object.kind === kind).map((object) => object.id));
43
+ this.idSnapshots.set(key, snapshot);
44
+ }
45
+ return snapshot;
46
+ }
47
+ onStructureChange(listener) {
48
+ this.structureListeners.add(listener);
49
+ return () => this.structureListeners.delete(listener);
50
+ }
51
+ removeOwner(ownerId, kinds) {
52
+ for (const [id, object] of this.objectsById) {
53
+ if (object.ownerId === ownerId && (!kinds || kinds.has(object.kind))) {
54
+ this.objectsById.delete(id);
55
+ this.releaseBytes(id);
56
+ this.emit({ type: 'remove', id, from: ownerId });
57
+ }
58
+ }
59
+ for (const [id, removal] of this.removedById)
60
+ if (!kinds && removal.ownerId === ownerId) {
61
+ this.removedById.delete(id);
62
+ this.releaseBytes(id);
63
+ }
64
+ this.structureChanged();
65
+ }
66
+ pruneOwner(ownerId, visible) {
67
+ let changed = false;
68
+ for (const [id, object] of this.objectsById) {
69
+ if (object.ownerId === ownerId && !visible.has(id)) {
70
+ this.objectsById.delete(id);
71
+ this.releaseBytes(id);
72
+ this.emit({ type: 'remove', id, from: ownerId });
73
+ changed = true;
74
+ }
75
+ }
76
+ if (changed)
77
+ this.structureChanged();
78
+ }
17
79
  snapshot() {
18
- const snapshot = {};
80
+ const snapshot = Object.create(null);
19
81
  for (const [id, object] of this.objectsById) {
20
82
  snapshot[id] = cloneObject(object);
21
83
  }
@@ -30,12 +92,28 @@ export class ObjectStore {
30
92
  }
31
93
  async upsert(input) {
32
94
  const previous = input.id ? this.objectsById.get(input.id) : undefined;
95
+ const removed = input.id ? this.removedById.get(input.id) : undefined;
96
+ if (!previous && !removed && this.objectsById.size + this.removedById.size >= this.capacity) {
97
+ throw new Error('Netcode object lifetime capacity exceeded; begin a new match.');
98
+ }
99
+ const ownerId = this.localPeerId() ?? 'local';
100
+ const kind = input.kind ?? previous?.kind ?? 'object';
101
+ if (!this.canWrite(kind))
102
+ throw new Error('Object state requires host authority.');
103
+ if (input.id && !input.id.startsWith(`${ownerId}:`))
104
+ throw new Error('Object IDs must be namespaced by their owner.');
105
+ this.assertOwner(input.ownerId ?? previous?.ownerId ?? removed?.ownerId ?? ownerId);
106
+ this.assertOwner(previous?.ownerId ?? removed?.ownerId ?? ownerId);
33
107
  const object = normalizeObject({
34
108
  ...previous,
35
109
  ...input,
36
110
  id: input.id ?? previous?.id ?? this.createObjectId(input.kind),
37
- kind: input.kind ?? previous?.kind ?? 'object',
38
- ownerId: input.ownerId ?? previous?.ownerId ?? this.localPeerId() ?? 'local',
111
+ kind,
112
+ ownerId,
113
+ revision: nextRevision(previous?.revision ?? removed?.revision ?? 0),
114
+ incarnation: this.incarnation(kind),
115
+ tick: input.tick ?? this.tick(),
116
+ teleport: input.teleport ?? false,
39
117
  transform: {
40
118
  ...(previous?.transform ?? {}),
41
119
  ...(input.transform ?? {}),
@@ -50,7 +128,10 @@ export class ObjectStore {
50
128
  },
51
129
  updatedAt: input.updatedAt ?? Date.now(),
52
130
  });
53
- this.applyObject(object, 'local');
131
+ if (!isNetcodeObjectState(object))
132
+ throw new Error('openrtc-netcode invalid local object');
133
+ if (!this.applyObject(object, ownerId, 'local'))
134
+ throw new Error('Netcode object byte capacity or version rejected.');
54
135
  await this.publishUpsert(object);
55
136
  return cloneObject(object);
56
137
  }
@@ -79,8 +160,16 @@ export class ObjectStore {
79
160
  });
80
161
  }
81
162
  async remove(id) {
82
- this.applyRemove(id, 'local');
83
- await this.publishRemove(id);
163
+ const previous = this.objectsById.get(id) ?? this.removedById.get(id);
164
+ if (!previous)
165
+ return;
166
+ if (!this.canWrite(this.objectsById.get(id)?.kind))
167
+ throw new Error('Object state requires host authority.');
168
+ this.assertOwner(previous.ownerId);
169
+ const removal = { id, ownerId: previous.ownerId, revision: nextRevision(previous.revision) };
170
+ if (!this.applyRemove(id, removal.ownerId, removal, 'local'))
171
+ throw new Error('Netcode object byte capacity or version rejected.');
172
+ await this.publishRemove(removal);
84
173
  }
85
174
  onChange(listener) {
86
175
  this.listeners.add(listener);
@@ -88,56 +177,179 @@ export class ObjectStore {
88
177
  }
89
178
  clear() {
90
179
  this.objectsById.clear();
180
+ this.removedById.clear();
181
+ this.entryBytes.clear();
182
+ this.bytes = 0;
183
+ this.structureChanged();
91
184
  this.emit({ type: 'snapshot', objects: [], from: 'local' });
92
185
  }
93
- applySnapshot(objects, from) {
94
- for (const object of Object.values(objects)) {
95
- this.applyObject(object, from);
186
+ removalSnapshot() {
187
+ return Object.fromEntries(Array.from(this.removedById, ([id, removal]) => [id, { ...removal }]));
188
+ }
189
+ applySnapshot(objects, from, removed = {}) {
190
+ const acceptedObjects = Object.create(null);
191
+ const acceptedRemovals = Object.create(null);
192
+ this.snapshotBatch = true;
193
+ try {
194
+ for (const [id, removal] of Object.entries(removed)) {
195
+ if (id === removal.id && this.applyRemove(id, from, removal)) {
196
+ acceptedRemovals[id] = { ...this.removedById.get(id) };
197
+ }
198
+ }
199
+ for (const [id, object] of Object.entries(objects)) {
200
+ if (id === object.id && this.applyObject(object, from)) {
201
+ acceptedObjects[id] = this.get(id);
202
+ }
203
+ }
96
204
  }
97
- this.emit({ type: 'snapshot', objects: this.list(), from });
205
+ finally {
206
+ this.snapshotBatch = false;
207
+ if (this.structurePending) {
208
+ this.structurePending = false;
209
+ this.structureChanged();
210
+ }
211
+ }
212
+ if (Object.keys(acceptedObjects).length || Object.keys(acceptedRemovals).length) {
213
+ this.emit({ type: 'snapshot', objects: this.list(), from });
214
+ }
215
+ return { objects: acceptedObjects, removed: acceptedRemovals };
98
216
  }
99
217
  applyUpsert(object, from) {
100
- this.applyObject(object, from);
218
+ return this.applyObject(object, from);
101
219
  }
102
- applyRemove(id, from) {
220
+ applyRemove(id, sender, removal, from = sender) {
221
+ if (!isObjectRemoval(removal) || removal.id !== id || removal.ownerId !== sender || !id.startsWith(`${sender}:`))
222
+ return false;
223
+ const previous = this.objectsById.get(id) ?? this.removedById.get(id);
224
+ if (previous && (previous.ownerId !== sender || previous.revision > removal.revision))
225
+ return false;
226
+ if (this.removedById.get(id)?.revision === removal.revision)
227
+ return false;
228
+ if (!previous && this.objectsById.size + this.removedById.size >= this.capacity)
229
+ return false;
230
+ const value = { id, ownerId: sender, revision: removal.revision };
231
+ const bytes = jsonByteLength([id, value], MAX_OBJECT_BYTES);
232
+ if (!this.fitsBytes(id, bytes))
233
+ return false;
234
+ this.setBytes(id, bytes);
235
+ this.removedById.set(id, value);
103
236
  const existed = this.objectsById.delete(id);
104
237
  if (existed) {
238
+ this.structureChanged();
105
239
  this.emit({ type: 'remove', id, from });
106
240
  }
241
+ return true;
107
242
  }
108
- applyObject(input, from) {
109
- const object = normalizeObject(input);
243
+ applyObject(input, sender, from = sender) {
244
+ if (!isNetcodeObjectState(input) || input.ownerId !== sender)
245
+ return false;
246
+ if (!input.id.startsWith(`${sender}:`))
247
+ return false;
248
+ const removed = this.removedById.get(input.id);
249
+ if (removed && (removed.ownerId !== sender || removed.revision >= input.revision))
250
+ return false;
251
+ let object, bytes;
252
+ try {
253
+ object = normalizeObject(input);
254
+ bytes = jsonByteLength([object.id, object], MAX_OBJECT_BYTES);
255
+ }
256
+ catch {
257
+ return false;
258
+ }
110
259
  const previous = this.objectsById.get(object.id);
111
- if (previous && previous.updatedAt > object.updatedAt) {
112
- return;
260
+ if (!previous && !removed && this.objectsById.size + this.removedById.size >= this.capacity)
261
+ return false;
262
+ if (previous && (previous.ownerId !== sender || previous.revision > object.revision))
263
+ return false;
264
+ // Canonical payload order makes conflicting same-owner versions converge
265
+ // independently of delivery order. Duplicate versions emit no change.
266
+ if (previous?.revision === object.revision && canonicalJson(previous) >= canonicalJson(object)) {
267
+ return false;
268
+ }
269
+ if (!this.fitsBytes(object.id, bytes))
270
+ return false;
271
+ this.setBytes(object.id, bytes);
272
+ this.removedById.delete(object.id);
273
+ this.objectsById.set(object.id, freezeJson(object));
274
+ if (!previous || previous.kind !== object.kind || canonicalJson(previous.metadata) !== canonicalJson(object.metadata))
275
+ this.structureChanged();
276
+ if (!this.snapshotBatch)
277
+ this.emit({ type: 'upsert', object: cloneObject(object), from });
278
+ return true;
279
+ }
280
+ assertOwner(ownerId) {
281
+ if (ownerId !== (this.localPeerId() ?? 'local')) {
282
+ throw new Error(`openrtc-netcode object writes require the local owner: ${ownerId}`);
113
283
  }
114
- this.objectsById.set(object.id, object);
115
- this.emit({ type: 'upsert', object: cloneObject(object), from });
116
284
  }
117
285
  createObjectId(kind = 'object') {
118
286
  const peerId = this.localPeerId() ?? 'local';
119
287
  this.nextObjectId += 1;
120
- return `${peerId}:${kind}:${this.nextObjectId}`;
288
+ return `${peerId}:${this.instanceId}:${kind}:${this.nextObjectId}`;
289
+ }
290
+ structureChanged() {
291
+ if (this.snapshotBatch) {
292
+ this.structurePending = true;
293
+ return;
294
+ }
295
+ this.idSnapshots.clear();
296
+ for (const listener of this.structureListeners)
297
+ listener();
121
298
  }
122
299
  emit(change) {
300
+ if (this.snapshotBatch)
301
+ return;
123
302
  for (const listener of this.listeners) {
124
303
  listener(change);
125
304
  }
126
305
  }
306
+ fitsBytes(id, bytes) {
307
+ return this.bytes - (this.entryBytes.get(id) ?? 0) + bytes <= MAX_STORE_BYTES;
308
+ }
309
+ setBytes(id, bytes) {
310
+ this.bytes += bytes - (this.entryBytes.get(id) ?? 0);
311
+ this.entryBytes.set(id, bytes);
312
+ }
313
+ releaseBytes(id) {
314
+ this.bytes -= this.entryBytes.get(id) ?? 0;
315
+ this.entryBytes.delete(id);
316
+ }
127
317
  }
128
318
  export function isNetcodeObjectState(value) {
129
- if (!isRecord(value)) {
319
+ if (!isRecord(value) || !boundedJson(value)) {
130
320
  return false;
131
321
  }
132
- return typeof value.id === 'string'
133
- && typeof value.kind === 'string'
134
- && typeof value.ownerId === 'string'
322
+ return isId(value.id) && isId(value.kind) && isId(value.ownerId)
323
+ && typeof value.incarnation === 'string' && value.incarnation.length > 0 && value.incarnation.length <= 128
324
+ && typeof value.tick === 'number' && Number.isSafeInteger(value.tick) && value.tick >= 0
135
325
  && isRecord(value.transform)
136
326
  && isVector3(value.transform.position)
137
- && typeof value.updatedAt === 'number';
327
+ && (value.transform.scale === undefined || isVector3(value.transform.scale))
328
+ && (value.transform.rotation === undefined || isQuaternion(value.transform.rotation))
329
+ && (value.physics === undefined || (isRecord(value.physics)
330
+ && (value.physics.linearVelocity === undefined || isVector3(value.physics.linearVelocity))
331
+ && (value.physics.angularVelocity === undefined || isVector3(value.physics.angularVelocity))
332
+ && (value.physics.sleeping === undefined || typeof value.physics.sleeping === 'boolean')))
333
+ && (value.metadata === undefined || isRecord(value.metadata))
334
+ && (value.teleport === undefined || typeof value.teleport === 'boolean')
335
+ && isRevision(value.revision)
336
+ && typeof value.updatedAt === 'number' && Number.isFinite(value.updatedAt);
337
+ }
338
+ export function isObjectRemoval(value) {
339
+ return isRecord(value) && isId(value.id) && isId(value.ownerId)
340
+ && isRevision(value.revision);
341
+ }
342
+ function isRevision(value) {
343
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
344
+ }
345
+ function nextRevision(previous) {
346
+ const revision = previous + 1;
347
+ if (!isRevision(revision))
348
+ throw new Error('openrtc-netcode object revision exhausted');
349
+ return revision;
138
350
  }
139
351
  function normalizeObject(input) {
140
- return {
352
+ return cloneObject({
141
353
  id: input.id,
142
354
  kind: input.kind,
143
355
  ownerId: input.ownerId,
@@ -152,8 +364,12 @@ function normalizeObject(input) {
152
364
  sleeping: !!input.physics.sleeping,
153
365
  } : undefined,
154
366
  metadata: input.metadata ? { ...input.metadata } : {},
367
+ revision: input.revision,
368
+ incarnation: input.incarnation,
369
+ tick: input.tick,
370
+ teleport: input.teleport,
155
371
  updatedAt: input.updatedAt,
156
- };
372
+ });
157
373
  }
158
374
  function normalizeVector3(value) {
159
375
  return {
@@ -163,25 +379,47 @@ function normalizeVector3(value) {
163
379
  };
164
380
  }
165
381
  function normalizeQuaternion(value) {
166
- return {
167
- x: finiteNumber(value?.x),
168
- y: finiteNumber(value?.y),
169
- z: finiteNumber(value?.z),
170
- w: finiteNumber(value?.w, 1),
171
- };
382
+ const result = { x: finiteNumber(value?.x), y: finiteNumber(value?.y), z: finiteNumber(value?.z), w: finiteNumber(value?.w, 1) };
383
+ const norm = Math.hypot(result.x, result.y, result.z, result.w);
384
+ if (!Number.isFinite(norm) || norm < 1e-8)
385
+ throw new Error('Invalid object quaternion.');
386
+ for (const axis of ['x', 'y', 'z', 'w'])
387
+ result[axis] /= norm;
388
+ return result;
172
389
  }
173
390
  function finiteNumber(value, fallback = 0) {
174
- return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
391
+ if (value === undefined)
392
+ return fallback;
393
+ if (typeof value !== 'number' || !Number.isFinite(value))
394
+ throw new Error('Invalid object number.');
395
+ return value;
396
+ }
397
+ function isQuaternion(value) {
398
+ if (!isRecord(value))
399
+ return false;
400
+ const components = ['x', 'y', 'z', 'w'].map((axis) => value[axis]);
401
+ if (!components.every((part) => typeof part === 'number' && Number.isFinite(part)))
402
+ return false;
403
+ const norm = Math.hypot(...components);
404
+ return Number.isFinite(norm) && norm > 1e-8;
175
405
  }
176
406
  function isVector3(value) {
177
407
  return isRecord(value)
178
- && typeof value.x === 'number'
179
- && typeof value.y === 'number'
180
- && typeof value.z === 'number';
408
+ && typeof value.x === 'number' && Number.isFinite(value.x)
409
+ && typeof value.y === 'number' && Number.isFinite(value.y)
410
+ && typeof value.z === 'number' && Number.isFinite(value.z);
181
411
  }
182
412
  function isRecord(value) {
183
413
  return typeof value === 'object' && value !== null && !Array.isArray(value);
184
414
  }
185
415
  function cloneObject(object) {
186
- return JSON.parse(JSON.stringify(object));
416
+ return cloneJson(object, MAX_OBJECT_BYTES);
417
+ }
418
+ function canonicalJson(value) {
419
+ if (Array.isArray(value))
420
+ return `[${value.map(canonicalJson).join(',')}]`;
421
+ if (isRecord(value)) {
422
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
423
+ }
424
+ return JSON.stringify(value);
187
425
  }
@@ -1,6 +1,6 @@
1
1
  export declare const NETCODE_PROTOCOL = "openrtc-netcode";
2
- export declare const PROTOCOL_VERSION = 1;
3
- export type NetcodeEnvelopeKind = 'hello' | 'peer' | 'user' | 'state.snapshot' | 'state.patch' | 'object.snapshot' | 'object.upsert' | 'object.remove' | 'lobby.meta' | 'member.meta' | 'ping' | 'pong';
2
+ export declare const PROTOCOL_VERSION = 2;
3
+ export type NetcodeEnvelopeKind = 'hello' | 'peer' | 'user' | 'state.snapshot' | 'state.patch' | 'object.snapshot' | 'object.upsert' | 'object.remove' | 'object.batch' | 'action' | 'action.receipt' | 'lobby.meta' | 'member.meta' | 'ping' | 'pong';
4
4
  export interface NetcodeEnvelope<TBody = unknown> {
5
5
  protocol: typeof NETCODE_PROTOCOL;
6
6
  v: typeof PROTOCOL_VERSION;
@@ -9,12 +9,17 @@ export interface NetcodeEnvelope<TBody = unknown> {
9
9
  from: string;
10
10
  seq: number;
11
11
  sentAt: number;
12
+ sessionId: string;
12
13
  target?: string;
14
+ targetSessionId?: string;
15
+ targetAuthorityEpoch?: string;
13
16
  body: TBody;
14
17
  }
15
18
  export interface HelloBody {
16
19
  peerId: string;
17
20
  displayName?: string;
21
+ version: typeof PROTOCOL_VERSION;
22
+ authorityEpoch: string;
18
23
  }
19
24
  export interface UserMessageBody {
20
25
  type: string;
@@ -23,20 +28,28 @@ export interface UserMessageBody {
23
28
  export interface StateSnapshotBody {
24
29
  values: Record<string, unknown>;
25
30
  owners: Record<string, string>;
31
+ revisions: Record<string, number>;
26
32
  }
27
33
  export interface StatePatchBody {
28
34
  key: string;
29
35
  value: unknown;
30
36
  owner: string;
37
+ revision: number;
31
38
  }
32
39
  export interface ObjectSnapshotBody {
33
40
  objects: Record<string, unknown>;
41
+ removed?: Record<string, ObjectRemoveBody>;
42
+ viewRevision?: number;
43
+ page?: number;
44
+ pages?: number;
34
45
  }
35
46
  export interface ObjectUpsertBody {
36
47
  object: unknown;
37
48
  }
38
49
  export interface ObjectRemoveBody {
39
50
  id: string;
51
+ ownerId: string;
52
+ revision: number;
40
53
  }
41
54
  export interface MetadataBody {
42
55
  values: Record<string, string>;
package/dist/protocol.js CHANGED
@@ -1,11 +1,14 @@
1
+ import { boundedJson, isId, MAX_FRAME_BYTES } from './validation.js';
1
2
  export const NETCODE_PROTOCOL = 'openrtc-netcode';
2
- export const PROTOCOL_VERSION = 1;
3
+ export const PROTOCOL_VERSION = 2;
3
4
  export function encodeEnvelope(envelope) {
4
5
  return JSON.stringify(envelope);
5
6
  }
6
7
  export function decodeEnvelope(value) {
8
+ if (typeof value === 'string' && new TextEncoder().encode(value).byteLength > MAX_FRAME_BYTES)
9
+ return null;
7
10
  const candidate = typeof value === 'string' ? parseJson(value) : value;
8
- if (!isRecord(candidate)) {
11
+ if (!isRecord(candidate) || !boundedJson(candidate, 25000, 28)) {
9
12
  return null;
10
13
  }
11
14
  if (candidate.protocol !== NETCODE_PROTOCOL || candidate.v !== PROTOCOL_VERSION) {
@@ -14,12 +17,21 @@ export function decodeEnvelope(value) {
14
17
  if (!isEnvelopeKind(candidate.kind)) {
15
18
  return null;
16
19
  }
17
- if (typeof candidate.lobbyId !== 'string'
18
- || typeof candidate.from !== 'string'
20
+ if (!isId(candidate.lobbyId)
21
+ || !isId(candidate.from)
19
22
  || typeof candidate.seq !== 'number'
20
- || typeof candidate.sentAt !== 'number') {
23
+ || typeof candidate.sentAt !== 'number'
24
+ || typeof candidate.sessionId !== 'string' || candidate.sessionId.length === 0 || candidate.sessionId.length > 128
25
+ || !Number.isSafeInteger(candidate.seq) || candidate.seq < 1
26
+ || !Number.isFinite(candidate.sentAt)) {
21
27
  return null;
22
28
  }
29
+ if ('targetSessionId' in candidate && candidate.targetSessionId !== undefined
30
+ && (typeof candidate.targetSessionId !== 'string' || candidate.targetSessionId.length < 1 || candidate.targetSessionId.length > 128))
31
+ return null;
32
+ if ('targetAuthorityEpoch' in candidate && candidate.targetAuthorityEpoch !== undefined
33
+ && (typeof candidate.targetAuthorityEpoch !== 'string' || !candidate.targetAuthorityEpoch.length || candidate.targetAuthorityEpoch.length > 128))
34
+ return null;
23
35
  if ('target' in candidate && candidate.target !== undefined && typeof candidate.target !== 'string') {
24
36
  return null;
25
37
  }
@@ -34,6 +46,9 @@ export function isEnvelopeKind(value) {
34
46
  || value === 'object.snapshot'
35
47
  || value === 'object.upsert'
36
48
  || value === 'object.remove'
49
+ || value === 'object.batch'
50
+ || value === 'action'
51
+ || value === 'action.receipt'
37
52
  || value === 'lobby.meta'
38
53
  || value === 'member.meta'
39
54
  || value === 'ping'
@@ -0,0 +1,31 @@
1
+ export interface ReplicationOptions {
2
+ bytesPerSecond?: number;
3
+ burstBytes?: number;
4
+ maxPendingBytes?: number;
5
+ maxPendingObjects?: number;
6
+ maxStateAgeMs?: number;
7
+ }
8
+ export interface ReplicationStats {
9
+ queuedBytes: number;
10
+ oldestStateAgeMs: number;
11
+ sentBytes: number;
12
+ sentBatches: number;
13
+ superseded: number;
14
+ expired: number;
15
+ rejected: number;
16
+ }
17
+ /** Per-peer replacement, expiry and budgets. flush() is driven by the game tick. */
18
+ export declare class ReplicationScheduler<T> {
19
+ private readonly transmit;
20
+ private readonly peers;
21
+ readonly options: Required<ReplicationOptions>;
22
+ constructor(transmit: (peerId: string, values: T[]) => Promise<void | number>, options?: ReplicationOptions);
23
+ enqueue(peerId: string, key: string, value: T, bytes: number, priority?: number): Promise<void>;
24
+ flush(now?: number): void;
25
+ cancel(peerId: string, key: string): void;
26
+ closePeer(peerId: string): void;
27
+ close(): void;
28
+ diagnostics(peerId: string): ReplicationStats;
29
+ private peer;
30
+ private pump;
31
+ }