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/README.md +181 -40
- package/dist/actions.d.ts +40 -0
- package/dist/actions.js +269 -0
- package/dist/client.js +1049 -294
- package/dist/codec.d.ts +74 -0
- package/dist/codec.js +167 -0
- package/dist/framing.d.ts +9 -0
- package/dist/framing.js +39 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/dist/latestState.d.ts +4 -1
- package/dist/latestState.js +23 -19
- package/dist/motion.d.ts +9 -0
- package/dist/motion.js +12 -0
- package/dist/objects.d.ts +34 -5
- package/dist/objects.js +276 -38
- package/dist/protocol.d.ts +15 -2
- package/dist/protocol.js +20 -5
- package/dist/replication.d.ts +31 -0
- package/dist/replication.js +135 -0
- package/dist/roomRuntime.js +18 -16
- package/dist/simulation.d.ts +80 -0
- package/dist/simulation.js +269 -0
- package/dist/snapshots.d.ts +12 -0
- package/dist/snapshots.js +71 -0
- package/dist/state.d.ts +17 -4
- package/dist/state.js +162 -51
- package/dist/types.d.ts +50 -3
- package/dist/validation.d.ts +10 -0
- package/dist/validation.js +88 -0
- package/dist/voice.d.ts +2 -0
- package/dist/voice.js +81 -39
- package/package.json +2 -2
package/dist/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
|
|
38
|
-
ownerId
|
|
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
|
-
|
|
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.
|
|
83
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
133
|
-
&& typeof value.
|
|
134
|
-
&& typeof value.
|
|
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
|
-
&&
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/protocol.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare const NETCODE_PROTOCOL = "openrtc-netcode";
|
|
2
|
-
export declare const PROTOCOL_VERSION =
|
|
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 =
|
|
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 (
|
|
18
|
-
||
|
|
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
|
+
}
|