@pylonsync/realtime 0.18.1 → 0.19.1
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/connection.d.ts +32 -4
- package/dist/game.d.ts +6 -1
- package/dist/replication.d.ts +72 -1
- package/dist/wire.d.ts +89 -1
- package/package.json +1 -1
- package/src/connection-webtransport.test.ts +624 -0
- package/src/connection.test.ts +48 -1
- package/src/connection.ts +701 -94
- package/src/game.ts +16 -2
- package/src/replication.fixtures.json +2416 -0
- package/src/replication.test.ts +46 -0
- package/src/replication.ts +172 -2
- package/src/wire.test.ts +17 -0
- package/src/wire.ts +221 -1
package/src/replication.test.ts
CHANGED
|
@@ -35,6 +35,52 @@ describe("the Rust encoder's frames (replication.fixtures.json)", () => {
|
|
|
35
35
|
});
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
describe("the Rust encoder's datagrams (replication.fixtures.json)", () => {
|
|
39
|
+
test("apply, and skip, exactly as the Rust table does", () => {
|
|
40
|
+
const table = new EntityTable();
|
|
41
|
+
const tableJson = () =>
|
|
42
|
+
[...table.entities.values()]
|
|
43
|
+
.sort((a, b) => a.id - b.id)
|
|
44
|
+
.map((e) => ({
|
|
45
|
+
id: e.id,
|
|
46
|
+
q: [e.qx, e.qy, e.qz],
|
|
47
|
+
components: Object.fromEntries(
|
|
48
|
+
[...e.components.entries()].map(([k, v]) => [String(k), hex(v)]),
|
|
49
|
+
),
|
|
50
|
+
}));
|
|
51
|
+
for (const [i, event] of fixtures.datagrams.entries()) {
|
|
52
|
+
if ("stream" in event && event.stream) {
|
|
53
|
+
table.apply(bytes(event.stream), event.tick as number);
|
|
54
|
+
} else if ("datagram" in event && event.datagram) {
|
|
55
|
+
const s = table.applyDatagram(bytes(event.datagram));
|
|
56
|
+
expect(s.frame, `event ${i}`).toBe(event.frame);
|
|
57
|
+
expect(s.tick).toBe(event.tick);
|
|
58
|
+
expect(s.ack).toBe(event.ack);
|
|
59
|
+
expect(s.streamTick).toBe(event.sentStreamTick);
|
|
60
|
+
expect(s.parts).toBe(event.parts);
|
|
61
|
+
expect(s.updated, `event ${i}`).toEqual(event.updated);
|
|
62
|
+
expect(s.skipped, `event ${i}`).toBe(event.skipped);
|
|
63
|
+
expect(table.streamTick).toBe(event.streamTick);
|
|
64
|
+
}
|
|
65
|
+
expect(tableJson(), `after event ${i}`).toEqual(event.table as unknown as ReturnType<typeof tableJson>);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("hostile datagrams are refused, not half-applied", () => {
|
|
70
|
+
const t = new EntityTable();
|
|
71
|
+
expect(() => t.applyDatagram(new Uint8Array())).toThrow(ReplicationError);
|
|
72
|
+
expect(() => t.applyDatagram(new Uint8Array([1]))).toThrow("datagram version 1");
|
|
73
|
+
// version, frame 1, tick 1, ack 0, stream tick 1, precision 1.0,
|
|
74
|
+
// parts 1, count 1, id 0, then nothing.
|
|
75
|
+
expect(() =>
|
|
76
|
+
t.applyDatagram(new Uint8Array([2, 1, 1, 0, 1, 0, 0, 0x80, 0x3f, 1, 1, 0])),
|
|
77
|
+
).toThrow("ends early");
|
|
78
|
+
expect(() =>
|
|
79
|
+
t.applyDatagram(new Uint8Array([2, 1, 1, 0, 1, 0, 0, 0x80, 0x3f, 1, 0, 7])),
|
|
80
|
+
).toThrow("trailing bytes");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
38
84
|
describe("hostile frames", () => {
|
|
39
85
|
const head = (...rest: number[]) => {
|
|
40
86
|
const f = new Uint8Array(6 + rest.length);
|
package/src/replication.ts
CHANGED
|
@@ -23,9 +23,31 @@
|
|
|
23
23
|
* JavaScript numbers hold integers exactly up to 2^53. Entity ids and
|
|
24
24
|
* quantized positions must stay below that; a frame that exceeds it is
|
|
25
25
|
* refused rather than rounded.
|
|
26
|
+
*
|
|
27
|
+
* **Datagrams** (`pylon_replication::datagram`): on WebTransport, spawns,
|
|
28
|
+
* despawns, and full frames come as frames on the stream, and updates as
|
|
29
|
+
* datagrams that may be lost, duplicated, or reordered. A datagram update
|
|
30
|
+
* carries the entity's absolute position and the tick of the stream frame
|
|
31
|
+
* that spawned it (low 16 bits). {@link EntityTable.applyDatagram} applies
|
|
32
|
+
* an update only to the entity spawned at that tick and only when it is
|
|
33
|
+
* newer than the entity's last datagram, and skips it otherwise. Stream
|
|
34
|
+
* frames must go through `apply(frame, tick)` so the table knows the
|
|
35
|
+
* ticks; a full frame starts them over.
|
|
36
|
+
*
|
|
37
|
+
* ```text
|
|
38
|
+
* u8 version (2)
|
|
39
|
+
* varint frame number, tick, input ack, stream tick (of the last stream
|
|
40
|
+
* frame sent by this tick)
|
|
41
|
+
* f32 LE precision
|
|
42
|
+
* varint parts (datagrams sent for this tick)
|
|
43
|
+
* varint update count, per entity (ids ascending, delta-coded): id,
|
|
44
|
+
* u16 LE spawn tick, u8 mask, the masked axes (zigzag, absolute),
|
|
45
|
+
* components if bit 8
|
|
46
|
+
* ```
|
|
26
47
|
*/
|
|
27
48
|
|
|
28
49
|
export const REPLICATION_VERSION = 1;
|
|
50
|
+
export const DATAGRAM_VERSION = 2;
|
|
29
51
|
|
|
30
52
|
const FLAG_FULL = 1;
|
|
31
53
|
const MASK_X = 1;
|
|
@@ -57,6 +79,24 @@ export interface ReplicatedEntity {
|
|
|
57
79
|
components: Map<number, Uint8Array>;
|
|
58
80
|
}
|
|
59
81
|
|
|
82
|
+
/** What one datagram did. */
|
|
83
|
+
export interface DatagramSummary {
|
|
84
|
+
frame: number;
|
|
85
|
+
tick: number;
|
|
86
|
+
ack: number;
|
|
87
|
+
/** The tick of the last stream frame the server had sent by `tick`. */
|
|
88
|
+
streamTick: number;
|
|
89
|
+
/** Datagrams the server sent for `tick`. */
|
|
90
|
+
parts: number;
|
|
91
|
+
/** Entities it updated. */
|
|
92
|
+
updated: number[];
|
|
93
|
+
/**
|
|
94
|
+
* Updates it skipped: an unknown entity, another spawn, an older
|
|
95
|
+
* datagram than the entity's last, or another precision.
|
|
96
|
+
*/
|
|
97
|
+
skipped: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
60
100
|
/** What one frame did. */
|
|
61
101
|
export interface ReplicationSummary {
|
|
62
102
|
full: boolean;
|
|
@@ -65,6 +105,33 @@ export interface ReplicationSummary {
|
|
|
65
105
|
despawned: number[];
|
|
66
106
|
}
|
|
67
107
|
|
|
108
|
+
/** True when a replication frame is a full frame (it replaces the table). */
|
|
109
|
+
export function isFullFrame(frame: Uint8Array): boolean {
|
|
110
|
+
return frame.length >= 2 && (frame[1] & FLAG_FULL) !== 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A datagram's frame number, tick, and input ack, without applying it.
|
|
115
|
+
* Throws on bytes that are not a datagram.
|
|
116
|
+
*/
|
|
117
|
+
export function readDatagramHeader(datagram: Uint8Array): Omit<DatagramSummary, "updated" | "skipped"> {
|
|
118
|
+
const r = new Reader(datagram);
|
|
119
|
+
const version = r.u8();
|
|
120
|
+
if (version !== DATAGRAM_VERSION) throw new ReplicationError(`datagram version ${version}`);
|
|
121
|
+
const frame = toSafe(r.varint(), "frame number");
|
|
122
|
+
const tick = toSafe(r.varint(), "tick");
|
|
123
|
+
const ack = toSafe(r.varint(), "ack");
|
|
124
|
+
const streamTick = toSafe(r.varint(), "stream tick");
|
|
125
|
+
r.f32();
|
|
126
|
+
const parts = toSafe(r.varint(), "parts");
|
|
127
|
+
return { frame, tick, ack, streamTick, parts };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The low 16 bits of a spawn tick, as a datagram update carries them. */
|
|
131
|
+
function spawnTag(tick: number | undefined): number | undefined {
|
|
132
|
+
return tick === undefined ? undefined : tick % 0x10000;
|
|
133
|
+
}
|
|
134
|
+
|
|
68
135
|
class Reader {
|
|
69
136
|
private offset = 0;
|
|
70
137
|
private readonly view: DataView;
|
|
@@ -161,6 +228,18 @@ export class EntityTable {
|
|
|
161
228
|
readonly entities = new Map<number, ReplicatedEntity>();
|
|
162
229
|
/** World units per quantization step, from the last frame. */
|
|
163
230
|
precision = 0.01;
|
|
231
|
+
/**
|
|
232
|
+
* The tick of the stream frame that spawned each entity, when frames are
|
|
233
|
+
* applied with their tick. Datagram updates name it.
|
|
234
|
+
*/
|
|
235
|
+
readonly spawnTicks = new Map<number, number>();
|
|
236
|
+
/** The last datagram applied to each entity. */
|
|
237
|
+
readonly datagramFrames = new Map<number, number>();
|
|
238
|
+
/**
|
|
239
|
+
* The tick of the last frame applied with its tick. A client acks each
|
|
240
|
+
* datagram with it, so the server knows which spawns it had.
|
|
241
|
+
*/
|
|
242
|
+
streamTick = 0;
|
|
164
243
|
|
|
165
244
|
get size(): number {
|
|
166
245
|
return this.entities.size;
|
|
@@ -170,18 +249,32 @@ export class EntityTable {
|
|
|
170
249
|
return this.entities.get(id);
|
|
171
250
|
}
|
|
172
251
|
|
|
252
|
+
/** Forget everything. */
|
|
173
253
|
clear(): void {
|
|
174
254
|
this.entities.clear();
|
|
255
|
+
this.spawnTicks.clear();
|
|
256
|
+
this.datagramFrames.clear();
|
|
257
|
+
this.streamTick = 0;
|
|
175
258
|
}
|
|
176
259
|
|
|
177
|
-
|
|
260
|
+
/**
|
|
261
|
+
* Apply a replication frame. On a connection that also gets datagrams,
|
|
262
|
+
* pass the tick from the frame's header: the table records the tick each
|
|
263
|
+
* entity spawned at and the tick of the last frame, which datagrams and
|
|
264
|
+
* their acks name. A full frame starts that record over.
|
|
265
|
+
*/
|
|
266
|
+
apply(frame: Uint8Array, tick?: number): ReplicationSummary {
|
|
178
267
|
const r = new Reader(frame);
|
|
179
268
|
const version = r.u8();
|
|
180
269
|
if (version !== REPLICATION_VERSION) throw new ReplicationError(`version ${version}`);
|
|
181
270
|
const full = (r.u8() & FLAG_FULL) !== 0;
|
|
182
271
|
const precision = r.f32();
|
|
183
272
|
if (!Number.isFinite(precision) || precision <= 0) throw new ReplicationError("bad precision");
|
|
184
|
-
if (full)
|
|
273
|
+
if (full) {
|
|
274
|
+
this.entities.clear();
|
|
275
|
+
this.datagramFrames.clear();
|
|
276
|
+
this.spawnTicks.clear();
|
|
277
|
+
}
|
|
185
278
|
this.precision = precision;
|
|
186
279
|
const summary: ReplicationSummary = { full, spawned: [], updated: [], despawned: [] };
|
|
187
280
|
|
|
@@ -190,6 +283,8 @@ export class EntityTable {
|
|
|
190
283
|
for (let i = 0; i < n; i++) {
|
|
191
284
|
last = nextId(r, last);
|
|
192
285
|
this.entities.delete(last);
|
|
286
|
+
this.datagramFrames.delete(last);
|
|
287
|
+
this.spawnTicks.delete(last);
|
|
193
288
|
summary.despawned.push(last);
|
|
194
289
|
}
|
|
195
290
|
|
|
@@ -212,6 +307,9 @@ export class EntityTable {
|
|
|
212
307
|
z: qz * precision,
|
|
213
308
|
components,
|
|
214
309
|
});
|
|
310
|
+
this.datagramFrames.delete(last);
|
|
311
|
+
if (tick === undefined) this.spawnTicks.delete(last);
|
|
312
|
+
else this.spawnTicks.set(last, tick);
|
|
215
313
|
summary.spawned.push(last);
|
|
216
314
|
}
|
|
217
315
|
|
|
@@ -236,6 +334,78 @@ export class EntityTable {
|
|
|
236
334
|
e.y = e.qy * precision;
|
|
237
335
|
e.z = e.qz * precision;
|
|
238
336
|
}
|
|
337
|
+
if (tick !== undefined) this.streamTick = tick;
|
|
338
|
+
return summary;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Apply one datagram. It changes nothing it cannot apply exactly (see
|
|
343
|
+
* `DatagramSummary.skipped`). Throws only on bytes that are not a
|
|
344
|
+
* datagram.
|
|
345
|
+
*/
|
|
346
|
+
applyDatagram(datagram: Uint8Array): DatagramSummary {
|
|
347
|
+
const r = new Reader(datagram);
|
|
348
|
+
const version = r.u8();
|
|
349
|
+
if (version !== DATAGRAM_VERSION) throw new ReplicationError(`datagram version ${version}`);
|
|
350
|
+
const frame = toSafe(r.varint(), "frame number");
|
|
351
|
+
const tick = toSafe(r.varint(), "tick");
|
|
352
|
+
const ack = toSafe(r.varint(), "ack");
|
|
353
|
+
const streamTick = toSafe(r.varint(), "stream tick");
|
|
354
|
+
const precision = r.f32();
|
|
355
|
+
const parts = toSafe(r.varint(), "parts");
|
|
356
|
+
const n = r.varint();
|
|
357
|
+
if (n > 1n << 16n) throw new ReplicationError("count too large");
|
|
358
|
+
const summary: DatagramSummary = { frame, tick, ack, streamTick, parts, updated: [], skipped: 0 };
|
|
359
|
+
// Positions in another precision mean nothing to this table: the full
|
|
360
|
+
// frame of the new precision is still on its way.
|
|
361
|
+
const usable = precision === Math.fround(this.precision);
|
|
362
|
+
let last: number | null = null;
|
|
363
|
+
for (let i = 0; i < Number(n); i++) {
|
|
364
|
+
last = nextId(r, last);
|
|
365
|
+
const tag = r.u8() | (r.u8() << 8);
|
|
366
|
+
const mask = r.u8();
|
|
367
|
+
const qx = mask & MASK_X ? toSafe(r.zigzag(), "position") : null;
|
|
368
|
+
const qy = mask & MASK_Y ? toSafe(r.zigzag(), "position") : null;
|
|
369
|
+
const qz = mask & MASK_Z ? toSafe(r.zigzag(), "position") : null;
|
|
370
|
+
const changes = new Map<number, Uint8Array>();
|
|
371
|
+
const removed: number[] = [];
|
|
372
|
+
if (mask & MASK_COMPONENTS) {
|
|
373
|
+
const count = r.varint();
|
|
374
|
+
if (count > 256n) throw new ReplicationError("more than 256 components");
|
|
375
|
+
for (let c = 0; c < Number(count); c++) {
|
|
376
|
+
const id = r.u8();
|
|
377
|
+
const len = r.varint();
|
|
378
|
+
if (len === 0n) {
|
|
379
|
+
removed.push(id);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (len - 1n > BigInt(Number.MAX_SAFE_INTEGER)) throw new ReplicationError("component too large");
|
|
383
|
+
changes.set(id, r.take(Number(len - 1n)));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const e = this.entities.get(last);
|
|
387
|
+
const lastFrame = this.datagramFrames.get(last);
|
|
388
|
+
const current =
|
|
389
|
+
usable &&
|
|
390
|
+
e !== undefined &&
|
|
391
|
+
spawnTag(this.spawnTicks.get(last)) === tag &&
|
|
392
|
+
(lastFrame === undefined || frame > lastFrame);
|
|
393
|
+
if (!current || !e) {
|
|
394
|
+
summary.skipped += 1;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (qx !== null) e.qx = qx;
|
|
398
|
+
if (qy !== null) e.qy = qy;
|
|
399
|
+
if (qz !== null) e.qz = qz;
|
|
400
|
+
e.x = e.qx * this.precision;
|
|
401
|
+
e.y = e.qy * this.precision;
|
|
402
|
+
e.z = e.qz * this.precision;
|
|
403
|
+
for (const id of removed) e.components.delete(id);
|
|
404
|
+
for (const [id, bytes] of changes) e.components.set(id, bytes);
|
|
405
|
+
this.datagramFrames.set(last, frame);
|
|
406
|
+
summary.updated.push(last);
|
|
407
|
+
}
|
|
408
|
+
if (!r.done) throw new ReplicationError("trailing bytes");
|
|
239
409
|
return summary;
|
|
240
410
|
}
|
|
241
411
|
}
|
package/src/wire.test.ts
CHANGED
|
@@ -2,11 +2,13 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { encode as msgpackEncode, decode as msgpackDecode } from "@msgpack/msgpack";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
MAX_ACKS_PER_MESSAGE,
|
|
5
6
|
SHARD_HEADER_LEN,
|
|
6
7
|
ShardCodec,
|
|
7
8
|
ShardFrameKind,
|
|
8
9
|
decodeShardPayload,
|
|
9
10
|
decodeShardRejection,
|
|
11
|
+
encodeDatagramAckBatches,
|
|
10
12
|
encodeShardInput,
|
|
11
13
|
parseShardFrame,
|
|
12
14
|
} from "./wire";
|
|
@@ -70,3 +72,18 @@ test("inputs are MessagePack for MessagePack shards and JSON otherwise", () => {
|
|
|
70
72
|
expect(encodeShardInput(null, 5, 1)).toBe('{"input":5,"client_seq":1}');
|
|
71
73
|
expect(encodeShardInput(ShardCodec.Json, 5, 2)).toBe('{"input":5,"client_seq":2}');
|
|
72
74
|
});
|
|
75
|
+
|
|
76
|
+
test("ack batches stay within the datagram size", () => {
|
|
77
|
+
const acks = Array.from({ length: 1300 }, (_, i) => [2_000_000 + i, 1_000_000] as [number, number]);
|
|
78
|
+
const batches = encodeDatagramAckBatches(acks, 200);
|
|
79
|
+
let total = 0;
|
|
80
|
+
for (const b of batches) {
|
|
81
|
+
expect(b.length).toBeLessThanOrEqual(200);
|
|
82
|
+
expect(b[0]).toBe(1);
|
|
83
|
+
// The count is the second byte (under 128 here).
|
|
84
|
+
total += b[1];
|
|
85
|
+
}
|
|
86
|
+
expect(total).toBe(1300);
|
|
87
|
+
const big = encodeDatagramAckBatches(Array.from({ length: 1300 }, (_, i) => [i % 100, 1] as [number, number]), 1_000_000);
|
|
88
|
+
expect(big.length).toBe(Math.ceil(1300 / MAX_ACKS_PER_MESSAGE));
|
|
89
|
+
});
|
package/src/wire.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* message is a binary frame with an 18-byte header:
|
|
6
6
|
*
|
|
7
7
|
* 0 1 frame kind: 1 snapshot, 2 input rejected, 3 entity replication,
|
|
8
|
-
* 4 transfer (JSON `{ shard, ticket }`: the last frame; reconnect there)
|
|
8
|
+
* 4 transfer (JSON `{ shard, ticket }`: the last frame; reconnect there),
|
|
9
|
+
* 6 closing (WebTransport only, JSON `{ code, reason }`)
|
|
9
10
|
* 1 1 codec: 0 JSON, 1 MessagePack, 2 bincode, 3 custom, 4 replication
|
|
10
11
|
* 2 8 tick (u64 big-endian)
|
|
11
12
|
* 10 8 ack: highest client_seq the shard processed for this subscriber (0 = none)
|
|
@@ -13,6 +14,12 @@
|
|
|
13
14
|
*
|
|
14
15
|
* Inputs go up as `{ input, client_seq }`: JSON in a text frame, or the
|
|
15
16
|
* shard's codec in a binary frame.
|
|
17
|
+
*
|
|
18
|
+
* Over WebTransport (wire version 3 in Rust) the same frames travel on one
|
|
19
|
+
* bidirectional stream, each after a 4-byte big-endian length, and entity
|
|
20
|
+
* updates travel as QUIC datagrams (`EntityTable.applyDatagram`). The
|
|
21
|
+
* client's hello, its inputs, and its datagram acks are described at
|
|
22
|
+
* `encodeWebTransportHello`, `ShardClientMessage`, and `encodeDatagramAcks`.
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
25
|
import { decode as msgpackDecode, encode as msgpackEncode } from "@msgpack/msgpack";
|
|
@@ -30,6 +37,13 @@ export const ShardFrameKind = {
|
|
|
30
37
|
* last frame on the connection.
|
|
31
38
|
*/
|
|
32
39
|
Transfer: 4,
|
|
40
|
+
/** A datagram frame (wire version 3 WebSocket only). */
|
|
41
|
+
Datagram: 5,
|
|
42
|
+
/**
|
|
43
|
+
* WebTransport only: the server is about to close the session, with
|
|
44
|
+
* JSON `{ code, reason }` (`WEBTRANSPORT_CLOSE`). The client closes it.
|
|
45
|
+
*/
|
|
46
|
+
Closing: 6,
|
|
33
47
|
} as const;
|
|
34
48
|
|
|
35
49
|
/** Where the subscriber went: connect to `shard` with `ticket`. */
|
|
@@ -142,3 +156,209 @@ export function encodeShardInput(
|
|
|
142
156
|
if (codec === ShardCodec.MessagePack) return msgpackEncode(envelope);
|
|
143
157
|
return JSON.stringify(envelope);
|
|
144
158
|
}
|
|
159
|
+
|
|
160
|
+
/** Acks one message may carry (the server refuses more). */
|
|
161
|
+
export const MAX_ACKS_PER_MESSAGE = 512;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Type bytes of the client's messages on a WebTransport stream (the first
|
|
165
|
+
* byte; the rest is the message).
|
|
166
|
+
*/
|
|
167
|
+
export const ShardClientMessage = {
|
|
168
|
+
/** An input envelope in the shard's codec (MessagePack shards). */
|
|
169
|
+
Input: 0,
|
|
170
|
+
/** Datagram acks (`encodeDatagramAcks`). */
|
|
171
|
+
Acks: 1,
|
|
172
|
+
/** An input envelope as JSON. */
|
|
173
|
+
JsonInput: 2,
|
|
174
|
+
} as const;
|
|
175
|
+
|
|
176
|
+
/** WebTransport session close codes the server uses. */
|
|
177
|
+
export const WEBTRANSPORT_CLOSE = {
|
|
178
|
+
Normal: 0,
|
|
179
|
+
/** Refused: bad credentials, an unknown shard. */
|
|
180
|
+
Policy: 1,
|
|
181
|
+
/** The client broke the protocol. */
|
|
182
|
+
Protocol: 2,
|
|
183
|
+
/** Try again: the client was too slow, or the server was busy. */
|
|
184
|
+
Again: 3,
|
|
185
|
+
} as const;
|
|
186
|
+
|
|
187
|
+
/** What `GET /_pylon/shard/webtransport` returns. */
|
|
188
|
+
export interface WebTransportInfo {
|
|
189
|
+
/** The `https://` URL of the WebTransport endpoint. */
|
|
190
|
+
url: string;
|
|
191
|
+
/**
|
|
192
|
+
* SHA-256 hashes of the server's self-signed certificates, for
|
|
193
|
+
* `serverCertificateHashes`. Empty when the certificate has a public CA.
|
|
194
|
+
*/
|
|
195
|
+
certHashes: Uint8Array[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Parse the body of `GET /_pylon/shard/webtransport`. */
|
|
199
|
+
export function decodeWebTransportInfo(body: unknown): WebTransportInfo {
|
|
200
|
+
const raw = body as { url?: unknown; certHashes?: unknown };
|
|
201
|
+
if (typeof raw?.url !== "string") throw new Error("WebTransport info without a url");
|
|
202
|
+
const hashes = Array.isArray(raw.certHashes) ? raw.certHashes : [];
|
|
203
|
+
return {
|
|
204
|
+
url: raw.url,
|
|
205
|
+
certHashes: hashes.map((h) => {
|
|
206
|
+
if (typeof h !== "string") throw new Error("a WebTransport certificate hash is not a string");
|
|
207
|
+
const bin = atob(h);
|
|
208
|
+
const bytes = new Uint8Array(bin.length);
|
|
209
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
210
|
+
if (bytes.length !== 32) throw new Error(`a ${bytes.length}-byte certificate hash`);
|
|
211
|
+
return bytes;
|
|
212
|
+
}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function pushVarint(out: number[], v: number): void {
|
|
217
|
+
if (!Number.isSafeInteger(v) || v < 0) throw new Error(`varint out of range: ${v}`);
|
|
218
|
+
while (v >= 0x80) {
|
|
219
|
+
out.push((v % 0x80) | 0x80);
|
|
220
|
+
v = Math.floor(v / 0x80);
|
|
221
|
+
}
|
|
222
|
+
out.push(v);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Datagram acks: the type byte `ShardClientMessage.Acks`, a varint count,
|
|
227
|
+
* then per ack the datagram's frame number and `EntityTable.streamTick`
|
|
228
|
+
* when the client applied it, both varints. Sent as a datagram, or on the
|
|
229
|
+
* stream.
|
|
230
|
+
*/
|
|
231
|
+
export function encodeDatagramAcks(acks: ReadonlyArray<readonly [number, number]>): Uint8Array {
|
|
232
|
+
const out: number[] = [ShardClientMessage.Acks];
|
|
233
|
+
pushVarint(out, acks.length);
|
|
234
|
+
for (const [frame, applied] of acks) {
|
|
235
|
+
pushVarint(out, frame);
|
|
236
|
+
pushVarint(out, applied);
|
|
237
|
+
}
|
|
238
|
+
return Uint8Array.from(out);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Bytes `pushVarint` writes for `v`. */
|
|
242
|
+
function varintLen(v: number): number {
|
|
243
|
+
let n = 1;
|
|
244
|
+
while (v >= 0x80) {
|
|
245
|
+
v = Math.floor(v / 0x80);
|
|
246
|
+
n += 1;
|
|
247
|
+
}
|
|
248
|
+
return n;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* `encodeDatagramAcks` split into messages of at most `maxBytes` bytes and
|
|
253
|
+
* `MAX_ACKS_PER_MESSAGE` acks each, so every one fits in a datagram.
|
|
254
|
+
*/
|
|
255
|
+
export function encodeDatagramAckBatches(
|
|
256
|
+
acks: ReadonlyArray<readonly [number, number]>,
|
|
257
|
+
maxBytes: number,
|
|
258
|
+
): Uint8Array[] {
|
|
259
|
+
const out: Uint8Array[] = [];
|
|
260
|
+
let start = 0;
|
|
261
|
+
// Type byte plus the count, which is below 2^14 (two varint bytes).
|
|
262
|
+
let size = 3;
|
|
263
|
+
for (let i = 0; i < acks.length; i++) {
|
|
264
|
+
const [frame, applied] = acks[i];
|
|
265
|
+
const n = varintLen(frame) + varintLen(applied);
|
|
266
|
+
if (i > start && (size + n > maxBytes || i - start === MAX_ACKS_PER_MESSAGE)) {
|
|
267
|
+
out.push(encodeDatagramAcks(acks.slice(start, i)));
|
|
268
|
+
start = i;
|
|
269
|
+
size = 3;
|
|
270
|
+
}
|
|
271
|
+
size += n;
|
|
272
|
+
}
|
|
273
|
+
if (start < acks.length) out.push(encodeDatagramAcks(acks.slice(start)));
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** A message for a WebTransport stream: a 4-byte big-endian length, then `bytes`. */
|
|
278
|
+
export function lengthPrefixed(bytes: Uint8Array): Uint8Array {
|
|
279
|
+
const out = new Uint8Array(4 + bytes.length);
|
|
280
|
+
new DataView(out.buffer).setUint32(0, bytes.length);
|
|
281
|
+
out.set(bytes, 4);
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The first message on a WebTransport stream: who connects to which shard. */
|
|
286
|
+
export function encodeWebTransportHello(hello: {
|
|
287
|
+
shard: string;
|
|
288
|
+
sid: string;
|
|
289
|
+
ticket?: string;
|
|
290
|
+
token?: string;
|
|
291
|
+
}): Uint8Array {
|
|
292
|
+
return lengthPrefixed(new TextEncoder().encode(JSON.stringify(hello)));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* An input as a WebTransport stream message: `encodeShardInput`'s output
|
|
297
|
+
* after its type byte.
|
|
298
|
+
*/
|
|
299
|
+
export function encodeWebTransportInput(encoded: string | Uint8Array): Uint8Array {
|
|
300
|
+
const body = typeof encoded === "string" ? new TextEncoder().encode(encoded) : encoded;
|
|
301
|
+
const msg = new Uint8Array(1 + body.length);
|
|
302
|
+
msg[0] = typeof encoded === "string" ? ShardClientMessage.JsonInput : ShardClientMessage.Input;
|
|
303
|
+
msg.set(body, 1);
|
|
304
|
+
return lengthPrefixed(msg);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** A frame on a WebTransport stream larger than this ends the session. */
|
|
308
|
+
const MAX_STREAM_FRAME = 64 * 1024 * 1024;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Splits a WebTransport stream's bytes into its length-prefixed frames.
|
|
312
|
+
* Chunks can end anywhere: in a length, in a frame.
|
|
313
|
+
*/
|
|
314
|
+
export class StreamFrames {
|
|
315
|
+
private chunks: Uint8Array[] = [];
|
|
316
|
+
private buffered = 0;
|
|
317
|
+
|
|
318
|
+
/** Add a chunk and return the frames it completed. */
|
|
319
|
+
push(chunk: Uint8Array): ArrayBuffer[] {
|
|
320
|
+
this.chunks.push(chunk);
|
|
321
|
+
this.buffered += chunk.length;
|
|
322
|
+
const frames: ArrayBuffer[] = [];
|
|
323
|
+
while (this.buffered >= 4) {
|
|
324
|
+
const head = this.peek(4);
|
|
325
|
+
const len = new DataView(head.buffer, head.byteOffset, 4).getUint32(0);
|
|
326
|
+
if (len > MAX_STREAM_FRAME) throw new Error(`a ${len}-byte stream frame`);
|
|
327
|
+
if (this.buffered < 4 + len) break;
|
|
328
|
+
this.take(4);
|
|
329
|
+
const frame = this.take(len);
|
|
330
|
+
frames.push(frame.buffer.slice(frame.byteOffset, frame.byteOffset + frame.length) as ArrayBuffer);
|
|
331
|
+
}
|
|
332
|
+
return frames;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private peek(n: number): Uint8Array {
|
|
336
|
+
if (this.chunks[0].length >= n) return this.chunks[0].subarray(0, n);
|
|
337
|
+
const out = new Uint8Array(n);
|
|
338
|
+
let at = 0;
|
|
339
|
+
for (const c of this.chunks) {
|
|
340
|
+
const part = c.subarray(0, n - at);
|
|
341
|
+
out.set(part, at);
|
|
342
|
+
at += part.length;
|
|
343
|
+
if (at === n) break;
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private take(n: number): Uint8Array {
|
|
349
|
+
const out = this.peek(n);
|
|
350
|
+
let left = n;
|
|
351
|
+
while (left > 0) {
|
|
352
|
+
const c = this.chunks[0];
|
|
353
|
+
if (c.length <= left) {
|
|
354
|
+
this.chunks.shift();
|
|
355
|
+
left -= c.length;
|
|
356
|
+
} else {
|
|
357
|
+
this.chunks[0] = c.subarray(left);
|
|
358
|
+
left = 0;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
this.buffered -= n;
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
}
|