@pylonsync/realtime 0.19.0 → 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.
@@ -87,6 +87,8 @@ export interface ReplicationSummary {
87
87
  updated: number[];
88
88
  despawned: number[];
89
89
  }
90
+ /** True when a replication frame is a full frame (it replaces the table). */
91
+ export declare function isFullFrame(frame: Uint8Array): boolean;
90
92
  /**
91
93
  * A datagram's frame number, tick, and input ack, without applying it.
92
94
  * Throws on bytes that are not a datagram.
package/dist/wire.d.ts CHANGED
@@ -131,6 +131,11 @@ export declare function decodeWebTransportInfo(body: unknown): WebTransportInfo;
131
131
  * stream.
132
132
  */
133
133
  export declare function encodeDatagramAcks(acks: ReadonlyArray<readonly [number, number]>): Uint8Array;
134
+ /**
135
+ * `encodeDatagramAcks` split into messages of at most `maxBytes` bytes and
136
+ * `MAX_ACKS_PER_MESSAGE` acks each, so every one fits in a datagram.
137
+ */
138
+ export declare function encodeDatagramAckBatches(acks: ReadonlyArray<readonly [number, number]>, maxBytes: number): Uint8Array[];
134
139
  /** A message for a WebTransport stream: a 4-byte big-endian length, then `bytes`. */
135
140
  export declare function lengthPrefixed(bytes: Uint8Array): Uint8Array;
136
141
  /** The first message on a WebTransport stream: who connects to which shard. */
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.19.0",
6
+ "version": "0.19.1",
7
7
  "description": "Pylon realtime shard client pieces with no framework: the wire protocol, codecs, and the entity replication decoder and table.",
8
8
  "type": "module",
9
9
  "main": "./src/index.ts",
@@ -541,3 +541,84 @@ test("a closing frame refuses fixed credentials, though the browser gets no code
541
541
  expect(FakeWebTransport.sessions.length).toBe(1);
542
542
  client.close();
543
543
  });
544
+
545
+ test("a stream frame waits for its tick's datagrams, and the handlers get both with the tick's ack", async () => {
546
+ const { client, wt } = await openWithBaseline();
547
+ const seen: Array<{ spawned: number[]; updated: number[]; ack: number }> = [];
548
+ client.onReplication((_, summary, _tick, ack) => {
549
+ if (!summary.full) seen.push({ spawned: summary.spawned, updated: summary.updated, ack });
550
+ });
551
+ // Tick 2's stream frame: entity 4 spawns at x = 50 (q 5000).
552
+ const precision = [...new Uint8Array(new Float32Array([0.01]).buffer)];
553
+ const spawn = Uint8Array.from([1, 0, ...precision, 0, 1, 4, ...varint(10000), 0, 0, 0, 0]);
554
+ wt.sendFrames([frame(3, 4, 2, 3, spawn)]);
555
+ await settleSoon();
556
+ expect(client.entities.get(4)).toBeUndefined();
557
+ expect(seen).toEqual([]);
558
+
559
+ wt.sendDatagram(dg({ frame: 1, tick: 2, ack: 3, streamTick: 2, parts: 1 }, [[1, 150]]));
560
+ await until("tick 2", () => seen.length === 1);
561
+ expect(client.entities.get(4)?.qx).toBe(5000);
562
+ expect(seen[0]).toEqual({ spawned: [4], updated: [1], ack: 3 });
563
+ // Acked with the stream tick the table had then.
564
+ expect([...wt.datagramsIn[0]]).toEqual([1, 1, 1, 2]);
565
+ client.close();
566
+ });
567
+
568
+ test("a session whose datagrams stop arriving closes", async () => {
569
+ const errors: string[] = [];
570
+ const { client, wt } = await openWithBaseline();
571
+ client.onError((e) => errors.push(e.message));
572
+ // Every tick misses a part, so none is ever whole.
573
+ for (let tick = 2; tick <= 103; tick++) {
574
+ wt.sendDatagram(dg({ frame: tick, tick, ack: 0, parts: 2 }, [[1, tick]]));
575
+ }
576
+ await until("the close", () => wt.clientClose !== null);
577
+ expect(errors).toContain("WebTransport to shard field: datagrams stopped arriving");
578
+ client.close();
579
+ });
580
+
581
+ test("a session with no whole tick for 5 s closes, even when nothing arrives", async () => {
582
+ let clock = 1000;
583
+ const errors: string[] = [];
584
+ const client = connectShard("field", {
585
+ subscriberId: "p1",
586
+ baseUrl: "h",
587
+ ticket: "t1",
588
+ transport: "webtransport",
589
+ now: () => clock,
590
+ });
591
+ client.onError((e) => errors.push(e.message));
592
+ await until("the session", () => client.connected);
593
+ const wt = FakeWebTransport.sessions[FakeWebTransport.sessions.length - 1];
594
+ wt.sendFrames([frame(3, 4, 1, 0, fullFrame)]);
595
+ await until("the full frame", () => client.entities.size === 3);
596
+ // Silence: no stream frame, no datagram. The watchdog runs each second.
597
+ clock += 4000;
598
+ await new Promise((r) => setTimeout(r, 1100));
599
+ expect(wt.clientClose).toBeNull();
600
+ clock += 2000;
601
+ await until("the close", () => wt.clientClose !== null);
602
+ expect(errors).toContain("WebTransport to shard field: datagrams stopped arriving");
603
+ client.close();
604
+ });
605
+
606
+ test("a snapshot shard over WebTransport is not taken for a stall", async () => {
607
+ let clock = 1000;
608
+ const client = connectShard("field", {
609
+ subscriberId: "p1",
610
+ baseUrl: "h",
611
+ ticket: "t1",
612
+ transport: "webtransport",
613
+ now: () => clock,
614
+ });
615
+ await until("the session", () => client.connected);
616
+ const wt = FakeWebTransport.sessions[FakeWebTransport.sessions.length - 1];
617
+ wt.sendFrames([frame(1, 0, 1, 0, json({ n: 1 }))]);
618
+ await until("the snapshot", () => client.tick === 1);
619
+ clock += 10_000;
620
+ await new Promise((r) => setTimeout(r, 1100));
621
+ expect(wt.clientClose).toBeNull();
622
+ expect(client.connected).toBe(true);
623
+ client.close();
624
+ });
package/src/connection.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { ShardClock } from "./clock";
9
- import { EntityTable, readDatagramHeader, type ReplicationSummary } from "./replication";
9
+ import { EntityTable, isFullFrame, readDatagramHeader, type ReplicationSummary } from "./replication";
10
10
  import {
11
11
  SHARD_PROTOCOL_VERSION,
12
12
  ShardFrameKind,
@@ -14,8 +14,7 @@ import {
14
14
  StreamFrames,
15
15
  decodeWebTransportInfo,
16
16
  type WebTransportInfo,
17
- MAX_ACKS_PER_MESSAGE,
18
- encodeDatagramAcks,
17
+ encodeDatagramAckBatches,
19
18
  encodeWebTransportHello,
20
19
  encodeWebTransportInput,
21
20
  decodeShardPayload,
@@ -187,8 +186,8 @@ interface Link {
187
186
  readonly open: boolean;
188
187
  /** Send an input, as `encodeShardInput` made it. */
189
188
  send(input: string | Uint8Array): void;
190
- /** Send datagram acks (`encodeDatagramAcks`). WebTransport only. */
191
- ack(acks: Uint8Array): void;
189
+ /** Send datagram acks: (datagram number, stream tick). WebTransport only. */
190
+ ack(acks: Array<[number, number]>): void;
192
191
  /**
193
192
  * What the server said before it closed the session (a closing frame):
194
193
  * a WebTransport close from the server does not reach the browser's
@@ -207,8 +206,21 @@ interface PendingTick {
207
206
  datagrams: Map<number, Uint8Array>;
208
207
  }
209
208
 
210
- /** Ticks of datagrams kept waiting at most; older ones count as lost. */
211
- const MAX_PENDING_TICKS = 32;
209
+ /** A stream frame waiting until its tick is whole. */
210
+ interface PendingFrame {
211
+ tick: number;
212
+ payload: Uint8Array;
213
+ }
214
+
215
+ /**
216
+ * How long a WebTransport session may go without a whole tick (ms). Past
217
+ * that, its datagrams have stopped arriving: the client closes it. The
218
+ * server sends at least one datagram every tick.
219
+ */
220
+ const STALL_MS = 5000;
221
+
222
+ /** Ticks kept waiting at most; more means the datagrams have stopped. */
223
+ const MAX_PENDING_TICKS = 100;
212
224
 
213
225
  /** The parts of the browser's `WebTransport` the client uses. */
214
226
  interface WebTransportSession {
@@ -216,6 +228,8 @@ interface WebTransportSession {
216
228
  readonly closed: Promise<{ closeCode?: number; reason?: string } | undefined>;
217
229
  readonly datagrams: {
218
230
  readonly readable: ReadableStream<Uint8Array>;
231
+ /** The largest datagram the session can send now. */
232
+ readonly maxDatagramSize?: number;
219
233
  readonly writable?: WritableStream<Uint8Array>;
220
234
  createWritable?: () => WritableStream<Uint8Array>;
221
235
  };
@@ -286,6 +300,15 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
286
300
  // newer ticks that are not whole yet (see `pylon_replication::datagram`).
287
301
  let wholeTick = -1;
288
302
  const pending = new Map<number, PendingTick>();
303
+ // Stream frames (not full) that wait with their tick's datagrams, in
304
+ // order, and the tick of the newest one.
305
+ const pendingStream: PendingFrame[] = [];
306
+ let receivedStreamTick = -1;
307
+ // When the table last held a whole tick (or the session opened).
308
+ let lastWholeAt = 0;
309
+ // Whether this link replicates entities. Only then does the server send
310
+ // datagrams every tick; a snapshot shard sends none.
311
+ let replicating = false;
289
312
  let lastKind: Link["kind"] | null = null;
290
313
  let clientSeq = 0;
291
314
  let closed = false;
@@ -422,6 +445,10 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
422
445
  reportedTick = -1;
423
446
  wholeTick = -1;
424
447
  pending.clear();
448
+ pendingStream.length = 0;
449
+ receivedStreamTick = -1;
450
+ lastWholeAt = now();
451
+ replicating = false;
425
452
  // Inputs sent on the old connection are never acknowledged on this
426
453
  // one (acks restart with the connection).
427
454
  sentAt.clear();
@@ -452,25 +479,54 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
452
479
  };
453
480
 
454
481
  /**
455
- * Apply the buffered datagrams of the newest whole tick and of every
456
- * tick before it, oldest first, then take that tick's ack and ack the
457
- * datagrams. A tick is whole when all its datagrams are here and the
458
- * table has the stream frames sent by then.
482
+ * Apply everything buffered up to the newest whole tick, oldest tick
483
+ * first (each tick's stream frames, then its datagrams), then take that
484
+ * tick's ack, tell the handlers once, and ack the datagrams. A tick is
485
+ * whole when all its datagrams are here and so are the stream frames
486
+ * sent by then. Until then its changes wait, so the handlers never see a
487
+ * table newer than the ack they get with it (prediction relies on that).
459
488
  */
460
489
  const applyWhole = (from: Link, at: number) => {
490
+ try {
491
+ applyWholeTick(from, at);
492
+ } catch (e) {
493
+ // Out of sync with the server. Reconnecting gets a full baseline.
494
+ dispatchError(e instanceof Error ? e : new Error(String(e)));
495
+ entities.clear();
496
+ from.close();
497
+ }
498
+ };
499
+
500
+ const applyWholeTick = (from: Link, at: number) => {
461
501
  let whole = -1;
462
502
  for (const [tick, p] of pending) {
463
- if (tick > whole && p.datagrams.size === p.parts && entities.streamTick >= p.streamTick) {
503
+ if (tick > whole && p.datagrams.size === p.parts && receivedStreamTick >= p.streamTick) {
464
504
  whole = tick;
465
505
  }
466
506
  }
467
507
  if (whole < 0) return;
468
508
  const ack = (pending.get(whole) as PendingTick).ack;
469
- const ticks = [...pending.keys()].filter((t) => t <= whole).sort((a, b) => a - b);
509
+ const ticks = new Set<number>();
510
+ for (const t of pending.keys()) if (t <= whole) ticks.add(t);
511
+ for (const f of pendingStream) if (f.tick <= whole) ticks.add(f.tick);
512
+ const spawned = new Set<number>();
513
+ const despawned = new Set<number>();
470
514
  const updated = new Set<number>();
471
515
  const acks: Array<[number, number]> = [];
472
- for (const t of ticks) {
473
- const p = pending.get(t) as PendingTick;
516
+ for (const t of [...ticks].sort((a, b) => a - b)) {
517
+ while (pendingStream.length > 0 && pendingStream[0].tick === t) {
518
+ const f = pendingStream.shift() as PendingFrame;
519
+ const s = entities.apply(f.payload, f.tick);
520
+ for (const id of s.despawned) {
521
+ despawned.add(id);
522
+ spawned.delete(id);
523
+ updated.delete(id);
524
+ }
525
+ for (const id of s.spawned) spawned.add(id);
526
+ for (const id of s.updated) updated.add(id);
527
+ }
528
+ const p = pending.get(t);
529
+ if (!p) continue;
474
530
  pending.delete(t);
475
531
  for (const number of [...p.datagrams.keys()].sort((a, b) => a - b)) {
476
532
  const s = entities.applyDatagram(p.datagrams.get(number) as Uint8Array);
@@ -478,14 +534,39 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
478
534
  acks.push([s.frame, entities.streamTick]);
479
535
  }
480
536
  }
537
+ for (const id of spawned) updated.delete(id);
481
538
  wholeTick = whole;
539
+ lastWholeAt = at;
482
540
  takeAck(ack, at);
483
541
  backoff = options.reconnectBackoffMs ?? 500;
484
- report({ full: false, spawned: [], updated: [...updated], despawned: [] }, whole);
485
- for (let i = 0; i < acks.length; i += MAX_ACKS_PER_MESSAGE) {
486
- from.ack(encodeDatagramAcks(acks.slice(i, i + MAX_ACKS_PER_MESSAGE)));
542
+ report(
543
+ { full: false, spawned: [...spawned], updated: [...updated], despawned: [...despawned] },
544
+ whole,
545
+ );
546
+ if (acks.length > 0) from.ack(acks);
547
+ };
548
+
549
+ /**
550
+ * Too many ticks without one whole: the datagrams stopped arriving (UDP
551
+ * blocked partway, say). Close the session; `"auto"` uses WebSockets
552
+ * from then on.
553
+ */
554
+ const stalled = (from: Link) => {
555
+ if (
556
+ from !== link ||
557
+ !replicating ||
558
+ (now() - lastWholeAt <= STALL_MS &&
559
+ pending.size <= MAX_PENDING_TICKS &&
560
+ pendingStream.length <= MAX_PENDING_TICKS)
561
+ ) {
562
+ return false;
487
563
  }
564
+ dispatchError(new Error(`WebTransport to shard ${currentShard}: datagrams stopped arriving`));
565
+ if ((options.transport ?? "websocket") === "auto") webSocketOnly = true;
566
+ from.close();
567
+ return true;
488
568
  };
569
+
489
570
  /** A frame from the server: a WebSocket message, or one from the WebTransport stream. */
490
571
  const onFrame = (from: Link, data: ArrayBuffer, at: number) => {
491
572
  if (from !== link) return;
@@ -531,6 +612,14 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
531
612
  // tick's frame is built, and would make the clock run early.
532
613
  observeTick(frame.tick, at);
533
614
  }
615
+ if (frame.kind === ShardFrameKind.Replication) replicating = true;
616
+ if (frame.kind === ShardFrameKind.Replication && from.kind === "webtransport" && !isFullFrame(frame.payload)) {
617
+ // It waits with its tick's datagrams (see `applyWhole`).
618
+ pendingStream.push({ tick: frame.tick, payload: frame.payload });
619
+ receivedStreamTick = Math.max(receivedStreamTick, frame.tick);
620
+ if (!stalled(from)) applyWhole(from, at);
621
+ return;
622
+ }
534
623
  if (frame.kind === ShardFrameKind.Replication) {
535
624
  let summary: ReplicationSummary;
536
625
  try {
@@ -542,14 +631,15 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
542
631
  from.close();
543
632
  return;
544
633
  }
545
- // Over WebTransport a stream frame holds only the tick's spawns
546
- // and despawns; its ack describes the table once the tick's
547
- // datagrams are in too. A full frame holds everything.
548
- if (from.kind === "websocket" || summary.full) takeAck(frame.ack, at);
549
- if (from.kind === "webtransport" && summary.full) {
550
- // Datagrams built before it describe a table that is gone.
634
+ // A full frame holds everything, so its ack describes the table.
635
+ takeAck(frame.ack, at);
636
+ if (from.kind === "webtransport") {
637
+ // What was built before it describes a table that is gone.
551
638
  wholeTick = Math.max(wholeTick, frame.tick);
639
+ lastWholeAt = at;
640
+ receivedStreamTick = Math.max(receivedStreamTick, frame.tick);
552
641
  for (const t of [...pending.keys()]) if (t <= frame.tick) pending.delete(t);
642
+ while (pendingStream.length > 0 && pendingStream[0].tick <= frame.tick) pendingStream.shift();
553
643
  }
554
644
  // A frame applied: the connection works, so the next reconnect
555
645
  // starts from the short delay again. (Resetting on open would
@@ -587,16 +677,16 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
587
677
  if (from !== link) return;
588
678
  try {
589
679
  const h = readDatagramHeader(datagram);
680
+ replicating = true;
590
681
  if (h.tick <= wholeTick || h.parts < 1) return;
591
682
  observeTick(h.tick, at);
592
683
  let p = pending.get(h.tick);
593
684
  if (!p) {
594
685
  p = { parts: h.parts, streamTick: h.streamTick, ack: h.ack, datagrams: new Map() };
595
686
  pending.set(h.tick, p);
596
- if (pending.size > MAX_PENDING_TICKS) pending.delete(Math.min(...pending.keys()));
597
687
  }
598
688
  p.datagrams.set(h.frame, datagram);
599
- applyWhole(from, at);
689
+ if (!stalled(from)) applyWhole(from, at);
600
690
  } catch (e) {
601
691
  // Not a datagram this client can read: start over with a baseline.
602
692
  dispatchError(e instanceof Error ? e : new Error(String(e)));
@@ -781,7 +871,11 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
781
871
  writer.write(encodeWebTransportInput(input)).catch(ignore);
782
872
  },
783
873
  ack(acks) {
784
- datagrams.write(acks).catch(ignore);
874
+ // A datagram larger than the session allows never arrives.
875
+ const max = session.datagrams.maxDatagramSize ?? 1200;
876
+ for (const message of encodeDatagramAckBatches(acks, max)) {
877
+ datagrams.write(message).catch(ignore);
878
+ }
785
879
  },
786
880
  notice: null,
787
881
  close() {
@@ -794,6 +888,10 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
794
888
  },
795
889
  };
796
890
  began(l);
891
+ // Checks for a stall also while nothing arrives at all.
892
+ const watchdog = setInterval(() => {
893
+ if (link === l) stalled(l);
894
+ }, 1000);
797
895
 
798
896
  void (async () => {
799
897
  const reader = stream.readable.getReader();
@@ -833,6 +931,7 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
833
931
  // `closed` does not get them from a server close.
834
932
  const closedWith = (info: { closeCode?: number; reason?: string } | null) => {
835
933
  isOpen = false;
934
+ clearInterval(watchdog);
836
935
  const code = l.notice?.code ?? info?.closeCode;
837
936
  const reason = l.notice?.reason ?? info?.reason ?? "";
838
937
  const refused = code === WEBTRANSPORT_CLOSE.Policy && reason.startsWith("unauthorized");
@@ -105,6 +105,11 @@ export interface ReplicationSummary {
105
105
  despawned: number[];
106
106
  }
107
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
+
108
113
  /**
109
114
  * A datagram's frame number, tick, and input ack, without applying it.
110
115
  * Throws on bytes that are not a datagram.
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
@@ -238,6 +238,42 @@ export function encodeDatagramAcks(acks: ReadonlyArray<readonly [number, number]
238
238
  return Uint8Array.from(out);
239
239
  }
240
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
+
241
277
  /** A message for a WebTransport stream: a 4-byte big-endian length, then `bytes`. */
242
278
  export function lengthPrefixed(bytes: Uint8Array): Uint8Array {
243
279
  const out = new Uint8Array(4 + bytes.length);