@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.
@@ -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);
@@ -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
- apply(frame: Uint8Array): ReplicationSummary {
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) this.entities.clear();
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
+ }