@net-mesh/browser 0.42.0 → 0.44.0

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.md +12 -0
  3. package/dist/index.bundle.js +2 -2
  4. package/dist/leader/session.d.ts +4 -2
  5. package/dist/leader/session.js +30 -6
  6. package/dist/lobby.d.ts +29 -2
  7. package/dist/lobby.js +80 -10
  8. package/dist/net_leaf.d.ts +19 -21
  9. package/dist/net_leaf.js +42 -44
  10. package/dist/net_leaf_bg.wasm +0 -0
  11. package/dist/net_leaf_bg.wasm.d.ts +10 -10
  12. package/dist/netcode/assignment.d.ts +169 -0
  13. package/dist/netcode/assignment.js +321 -0
  14. package/dist/netcode/client.d.ts +125 -3
  15. package/dist/netcode/client.js +291 -27
  16. package/dist/netcode/codec.d.ts +180 -0
  17. package/dist/netcode/codec.js +547 -0
  18. package/dist/netcode/host.d.ts +58 -1
  19. package/dist/netcode/host.js +254 -9
  20. package/dist/netcode/index.d.ts +14 -4
  21. package/dist/netcode/index.js +8 -3
  22. package/dist/netcode/interpolate.d.ts +24 -1
  23. package/dist/netcode/interpolate.js +52 -1
  24. package/dist/netcode/paths.d.ts +115 -0
  25. package/dist/netcode/paths.js +261 -0
  26. package/dist/netcode/shards.d.ts +138 -0
  27. package/dist/netcode/shards.js +270 -0
  28. package/dist/netcode/wire.d.ts +37 -3
  29. package/dist/netcode/wire.js +28 -1
  30. package/dist/node.d.ts +20 -0
  31. package/dist/node.js +76 -1
  32. package/dist/store/host.js +10 -4
  33. package/dist/store/join.js +38 -2
  34. package/dist/store/owner.d.ts +11 -0
  35. package/dist/store/owner.js +27 -7
  36. package/dist/store/types.d.ts +6 -0
  37. package/dist/stream.d.ts +29 -1
  38. package/dist/stream.js +66 -4
  39. package/dist/udp-probe.d.ts +21 -3
  40. package/dist/udp-probe.js +78 -7
  41. package/dist/world/link.d.ts +9 -0
  42. package/dist/world/link.js +4 -3
  43. package/dist/world/regions.d.ts +9 -2
  44. package/dist/world/regions.js +34 -4
  45. package/package.json +3 -2
package/dist/stream.js CHANGED
@@ -17,13 +17,24 @@ export class StreamIdentityError extends SessionError {
17
17
  this.name = 'StreamIdentityError';
18
18
  }
19
19
  }
20
+ /** Most frames a {@link LeafStream} buffers before its first consumer. */
21
+ export const LEAF_STREAM_PENDING_MAX_FRAMES = 1024;
22
+ /** Most bytes a {@link LeafStream} buffers before its first consumer. */
23
+ export const LEAF_STREAM_PENDING_MAX_BYTES = 1 << 20;
20
24
  /**
21
25
  * One open stream.
22
26
  *
23
27
  * Inbound payloads are delivered to `for await` consumers and to
24
28
  * {@link LeafStream.onMessage} listeners. Payloads that arrive before
25
29
  * anyone consumes them are buffered in arrival order, so a page that
26
- * opens a stream and awaits it a tick later loses nothing.
30
+ * opens a stream and awaits it a tick later loses nothing — up to
31
+ * {@link LEAF_STREAM_PENDING_MAX_FRAMES} frames and
32
+ * {@link LEAF_STREAM_PENDING_MAX_BYTES} bytes. Past either bound the
33
+ * OLDEST buffered frame is dropped and counted in
34
+ * {@link LeafStream.droppedBeforeConsumer} (a single frame larger than the
35
+ * byte bound is never buffered, and is counted there too): a stream used only to send
36
+ * (the store, netcode and region-link layers read inbound traffic
37
+ * elsewhere) must not keep every frame the peer sends for its lifetime.
27
38
  */
28
39
  export class LeafStream {
29
40
  inner;
@@ -31,7 +42,11 @@ export class LeafStream {
31
42
  queues = new Set();
32
43
  listeners = new Set();
33
44
  pending = [];
45
+ pendingBytes = 0;
46
+ dropped = 0;
34
47
  closed = false;
48
+ /** Why the SESSION closed this stream, when it did so for a leader change. */
49
+ retiredBy = null;
35
50
  /**
36
51
  * This stream's wire id as a number, or `null` when the inner
37
52
  * object's `stream_id_hex()` was not hex.
@@ -94,6 +109,13 @@ export class LeafStream {
94
109
  }
95
110
  inner.on_message((event) => this.receive(event));
96
111
  }
112
+ /**
113
+ * Frames dropped from the pre-consumer buffer because no `for await`
114
+ * consumer or `onMessage` listener was attached and it was full.
115
+ */
116
+ get droppedBeforeConsumer() {
117
+ return this.dropped;
118
+ }
97
119
  /** The authenticated peer this stream is with, 16 lowercase hex. */
98
120
  get peerNode() {
99
121
  return this.inner.peer_node_hex();
@@ -113,7 +135,7 @@ export class LeafStream {
113
135
  /** Send one payload. Fragmentation at `MAX_PAYLOAD_SIZE` is the leaf's. */
114
136
  async send(payload) {
115
137
  if (this.closed)
116
- throw new SessionError('the stream is closed');
138
+ throw new SessionError(this.retiredBy ?? 'the stream is closed');
117
139
  try {
118
140
  await this.inner.send(payload);
119
141
  }
@@ -124,7 +146,7 @@ export class LeafStream {
124
146
  /** Listen for payloads. Returns a cancel handle. */
125
147
  onMessage(listener) {
126
148
  this.listeners.add(listener);
127
- const buffered = this.pending.splice(0, this.pending.length);
149
+ const buffered = this.drainPending();
128
150
  for (const payload of buffered)
129
151
  listener(payload);
130
152
  return () => {
@@ -134,13 +156,26 @@ export class LeafStream {
134
156
  [Symbol.asyncIterator]() {
135
157
  const queue = new AsyncQueue(() => this.queues.delete(queue));
136
158
  this.queues.add(queue);
137
- const buffered = this.pending.splice(0, this.pending.length);
159
+ const buffered = this.drainPending();
138
160
  for (const payload of buffered)
139
161
  queue.push(payload);
140
162
  if (this.closed)
141
163
  queue.end();
142
164
  return queue;
143
165
  }
166
+ /**
167
+ * @internal — the session closing this stream because the leader it
168
+ * was opened on is gone. Later sends fail with the leaf's own
169
+ * reopenable wording ("stale stream handle"), so a store that cached
170
+ * the stream reopens it on the new leader instead of failing every
171
+ * frame forever (SDK audit 2026-10-11).
172
+ */
173
+ retire(reason) {
174
+ if (this.closed)
175
+ return;
176
+ this.retiredBy = reason;
177
+ this.close();
178
+ }
144
179
  /** Close the stream and end every consumer. */
145
180
  close() {
146
181
  if (this.closed)
@@ -163,12 +198,39 @@ export class LeafStream {
163
198
  * taken as an already-decoded payload, which is what a
164
199
  * host-supplied {@link LeafWasmStreamLike} may deliver.
165
200
  */
201
+ /**
202
+ * Hand the pre-consumer buffer over, emptying it — count included. The
203
+ * byte count stayed at the drained total, so after a consumer drained the
204
+ * buffer and detached, rebuffering tripped the byte cap almost at once
205
+ * and evicted all but the newest frame on nearly every arrival (code
206
+ * review 2026-10-11, BROWSER-1).
207
+ */
208
+ drainPending() {
209
+ this.pendingBytes = 0;
210
+ return this.pending.splice(0, this.pending.length);
211
+ }
166
212
  receive(event) {
167
213
  const payload = this.decode(event);
168
214
  if (payload === null || this.closed)
169
215
  return;
170
216
  if (this.queues.size === 0 && this.listeners.size === 0) {
217
+ // A single frame over the byte cap is dropped and counted rather
218
+ // than buffered: the eviction loop below stops at one frame, so it
219
+ // would otherwise sit there past the cap (cubic review of PR #1228).
220
+ if (payload.byteLength > LEAF_STREAM_PENDING_MAX_BYTES) {
221
+ this.dropped += 1;
222
+ return;
223
+ }
171
224
  this.pending.push(payload);
225
+ this.pendingBytes += payload.byteLength;
226
+ // Bounded (SDK audit 2026-10-11): unbounded, an unread stream grew
227
+ // at the peer's send rate until the tab ran out of memory.
228
+ while (this.pending.length > LEAF_STREAM_PENDING_MAX_FRAMES ||
229
+ this.pendingBytes > LEAF_STREAM_PENDING_MAX_BYTES) {
230
+ const oldest = this.pending.shift();
231
+ this.pendingBytes -= oldest.byteLength;
232
+ this.dropped += 1;
233
+ }
172
234
  return;
173
235
  }
174
236
  for (const listener of this.listeners) {
@@ -53,7 +53,12 @@ export type StunProbeOutcome =
53
53
  readonly type: 'unanswered';
54
54
  readonly detail: string;
55
55
  }
56
- /** The probe was not run — no address to aim at, or it was disabled. */
56
+ /**
57
+ * The probe established nothing either way: it was not run (no address
58
+ * to aim at, disabled, or the connection failed before gathering), or
59
+ * it ran and was inconclusive (gathering completed with neither an
60
+ * answer nor a "no response" report). Never evidence of blocked UDP.
61
+ */
57
62
  | {
58
63
  readonly type: 'notRun';
59
64
  readonly reason: string;
@@ -76,6 +81,10 @@ export type ProbeEvent = {
76
81
  /** The probe connection's ICE state became `failed`. */
77
82
  | {
78
83
  readonly ice: 'failed';
84
+ }
85
+ /** Gathering completed (the end-of-candidates signal). */
86
+ | {
87
+ readonly gathering: 'complete';
79
88
  };
80
89
  /**
81
90
  * Is this candidate line server-reflexive? The `typ` token followed by
@@ -98,10 +107,19 @@ export declare function probeEventAnswers(event: ProbeEvent): boolean;
98
107
  * event answers, `notRun` when the engine failed the connection before
99
108
  * gathering a single candidate (nothing was sent, so silence is not
100
109
  * evidence — measured: Firefox without a media permission on an
101
- * IPv6-only network with no IPv4 route), `unanswered` otherwise. The
102
- * Rust probe's `probe_verdict`, the same three answers.
110
+ * IPv6-only network with no IPv4 route) or when gathering completed with
111
+ * neither an answer nor a "no response" report (inconclusive),
112
+ * `unanswered` otherwise. The Rust probe's `probe_verdict`, the same
113
+ * three answers.
103
114
  */
104
115
  export declare function stunProbeVerdict(events: readonly ProbeEvent[]): 'answered' | 'unanswered' | 'notRun';
116
+ /**
117
+ * Is this a `typ host` candidate whose address is a literal publicly
118
+ * routable IP (not private, loopback, link-local, CGNAT, unique-local or
119
+ * unspecified)? An obfuscated `.local` address is not known public. Mirrors
120
+ * the Rust `host_candidate_is_public`.
121
+ */
122
+ export declare function hostCandidateIsPublic(line: string): boolean;
105
123
  /** {@link probeEventAnswers} over everything one probe reported. */
106
124
  export declare function stunProbeAnswered(events: readonly ProbeEvent[]): boolean;
107
125
  /** One endpoint and what probing it established. */
package/dist/udp-probe.js CHANGED
@@ -61,15 +61,71 @@ export function probeEventAnswers(event) {
61
61
  * event answers, `notRun` when the engine failed the connection before
62
62
  * gathering a single candidate (nothing was sent, so silence is not
63
63
  * evidence — measured: Firefox without a media permission on an
64
- * IPv6-only network with no IPv4 route), `unanswered` otherwise. The
65
- * Rust probe's `probe_verdict`, the same three answers.
64
+ * IPv6-only network with no IPv4 route) or when gathering completed with
65
+ * neither an answer nor a "no response" report (inconclusive),
66
+ * `unanswered` otherwise. The Rust probe's `probe_verdict`, the same
67
+ * three answers.
66
68
  */
67
69
  export function stunProbeVerdict(events) {
68
70
  if (events.some(probeEventAnswers))
69
71
  return 'answered';
70
72
  const gathered = events.some((event) => 'candidate' in event);
71
73
  const failed = events.some((event) => 'ice' in event && event.ice === 'failed');
72
- return failed && !gathered ? 'notRun' : 'unanswered';
74
+ if (failed && !gathered)
75
+ return 'notRun';
76
+ // Gathering that COMPLETED with no reflexive candidate and no "no
77
+ // response" report is inconclusive ONLY when a host candidate carried a
78
+ // literal public address: an engine may suppress a srflx equal to it, so
79
+ // a host with no NAT would otherwise read as `udp-blocked` (SDK audit
80
+ // 2026-10-11). Behind NAT the srflx is never suppressed, so its absence
81
+ // is silence — what blocked UDP looks like in Chromium (browser matrix,
82
+ // PR #1228). Same rule as the Rust `probe_verdict`.
83
+ const completed = events.some((event) => 'gathering' in event);
84
+ const reportedNoResponse = events.some((event) => 'error' in event && event.error >= STUN_NO_RESPONSE_CODE);
85
+ const publicHost = events.some((event) => 'candidate' in event && hostCandidateIsPublic(event.candidate));
86
+ return completed && !reportedNoResponse && publicHost ? 'notRun' : 'unanswered';
87
+ }
88
+ /**
89
+ * Is this a `typ host` candidate whose address is a literal publicly
90
+ * routable IP (not private, loopback, link-local, CGNAT, unique-local or
91
+ * unspecified)? An obfuscated `.local` address is not known public. Mirrors
92
+ * the Rust `host_candidate_is_public`.
93
+ */
94
+ export function hostCandidateIsPublic(line) {
95
+ const tokens = line.trim().split(/\s+/);
96
+ let host = false;
97
+ for (let i = 0; i + 1 < tokens.length; i += 1) {
98
+ if (tokens[i] === 'typ' && tokens[i + 1] === 'host')
99
+ host = true;
100
+ }
101
+ const addr = tokens[4];
102
+ if (!host || addr === undefined)
103
+ return false;
104
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(addr);
105
+ if (v4 !== null) {
106
+ const o = v4.slice(1).map(Number);
107
+ if (o.some((n) => n > 255))
108
+ return false;
109
+ const [a, b] = o;
110
+ const isPrivate = a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
111
+ const loopback = a === 127;
112
+ const linkLocal = a === 169 && b === 254;
113
+ const unspecified = o.every((n) => n === 0);
114
+ const broadcast = o.every((n) => n === 255);
115
+ const cgnat = a === 100 && (b & 0xc0) === 64;
116
+ return !(isPrivate || loopback || linkLocal || unspecified || broadcast || cgnat);
117
+ }
118
+ if (!addr.includes(':') || !/^[0-9a-f:.]+$/i.test(addr))
119
+ return false;
120
+ const lower = addr.toLowerCase();
121
+ if (lower === '::' || lower === '::1')
122
+ return false;
123
+ const first = Number.parseInt(lower.startsWith(':') ? '0' : lower.split(':')[0] ?? '0', 16);
124
+ if (Number.isNaN(first))
125
+ return false;
126
+ const uniqueLocal = (first & 0xfe00) === 0xfc00;
127
+ const linkLocalV6 = (first & 0xffc0) === 0xfe80;
128
+ return !(uniqueLocal || linkLocalV6);
73
129
  }
74
130
  /** {@link probeEventAnswers} over everything one probe reported. */
75
131
  export function stunProbeAnswered(events) {
@@ -154,11 +210,24 @@ export async function probeStunBinding(rtcAddr, options = {}) {
154
210
  // load-bearing rather than a safety net.
155
211
  let noResponse = null;
156
212
  const timer = setTimeout(() => settle({ type: 'unanswered', detail: noResponse ?? 'no answer inside the probe deadline' }), timeoutMs);
213
+ // Completion with no reflexive candidate is inconclusive only when no
214
+ // "no response" report came AND a host candidate carried a literal
215
+ // public address: libwebrtc drops a reflexive candidate equal to it, so
216
+ // the anchor can have answered with nothing visible. Behind NAT, or
217
+ // with only `.local` hosts (Firefox, which also makes no "no response"
218
+ // report), the srflx is never suppressed and its absence is silence
219
+ // (`stunProbeVerdict`, the same rule).
220
+ let publicHost = false;
157
221
  const gatheringDone = () => {
158
- settle({
159
- type: 'unanswered',
160
- detail: noResponse ?? 'gathering completed with no reflexive candidate',
161
- });
222
+ if (noResponse === null && publicHost) {
223
+ settle({
224
+ type: 'notRun',
225
+ reason: 'gathering completed beside a public host with no reflexive candidate and no "no response" report: ' +
226
+ 'inconclusive',
227
+ });
228
+ return;
229
+ }
230
+ settle({ type: 'unanswered', detail: noResponse ?? 'gathering completed with no reflexive candidate' });
162
231
  };
163
232
  // Did the engine gather anything at all? A connection it fails
164
233
  // before the first candidate is one it could not gather for, and
@@ -179,6 +248,8 @@ export async function probeStunBinding(rtcAddr, options = {}) {
179
248
  return;
180
249
  }
181
250
  gathered = true;
251
+ if (hostCandidateIsPublic(candidate.candidate))
252
+ publicHost = true;
182
253
  // Host candidates are gathered regardless of what the network does
183
254
  // to UDP — Chromium even hides them behind an mDNS `.local` name —
184
255
  // so only a reflexive one is evidence.
@@ -109,7 +109,16 @@ export interface RegionHandoffsOptions<E> {
109
109
  };
110
110
  /** Outcomes: `moved`, `refused`, `unresolved` (source), `admitted` (target). */
111
111
  onEvent?(event: HandoffEvent): void;
112
+ /** Entity handoffs: re-offer, give up, and how long a decision is remembered. */
112
113
  readonly timing?: HandoffTiming;
114
+ /**
115
+ * Forwarded actions ({@link RegionHandoffs.forward}), when they need other
116
+ * timing than entity handoffs: a hit is worthless after half a second,
117
+ * while a moving entity must not be given up that soon (it would be live
118
+ * nowhere) nor forgotten while its offer may still be re-sent (it could
119
+ * be admitted twice). Default: `timing`.
120
+ */
121
+ readonly actionTiming?: HandoffTiming;
113
122
  /** How often to re-offer and prune, ms. Default 100. */
114
123
  readonly tickMs?: number;
115
124
  readonly now?: () => number;
@@ -264,9 +264,10 @@ export function regionHandoffs(options) {
264
264
  options.link.send({ to, body: frame });
265
265
  }
266
266
  };
267
- const retryMs = options.timing?.retryMs ?? 250;
268
- const giveUpMs = options.timing?.giveUpMs ?? 60_000;
269
- const retentionMs = options.timing?.handledRetentionMs ?? 600_000;
267
+ const actionTiming = options.actionTiming ?? options.timing;
268
+ const retryMs = actionTiming?.retryMs ?? 250;
269
+ const giveUpMs = actionTiming?.giveUpMs ?? 60_000;
270
+ const retentionMs = actionTiming?.handledRetentionMs ?? 600_000;
270
271
  const stop = options.link.onMessage(body => {
271
272
  if (closed)
272
273
  return;
@@ -44,8 +44,15 @@ export declare function regionsAround(x: number, z: number, options: {
44
44
  }): string[];
45
45
  /** The directory tag a region host announces. */
46
46
  export declare function regionTag(world: string, region: string): string;
47
- /** How often region hosts re-announce: an announcement is a lease. */
48
- export declare const REGION_ANNOUNCE_MS = 2000;
47
+ /**
48
+ * How often region hosts re-announce: an announcement is a lease, held
49
+ * for the leaf's 300 s TTL. 30 s, not 2 s, for the reason
50
+ * `LOBBY_ANNOUNCE_MS` gives: re-announcements flood through the anchor to
51
+ * every peer, and at 2 s they saturated a host's anchor link at 64 nodes.
52
+ * A failed announcement is retried sooner, as a lobby's is
53
+ * (`ANNOUNCE_RETRY_MS`, doubling up to `ANNOUNCE_RETRY_MAX_MS`).
54
+ */
55
+ export declare const REGION_ANNOUNCE_MS = 30000;
49
56
  /**
50
57
  * Announce that this node hosts `regions` of `world`, and keep announcing
51
58
  * (an announcement is a lease). `announce` replaces a node's tags, so pass
@@ -27,6 +27,7 @@
27
27
  * directory (`handoffLink({ peerOf: directory.peerOf })`) needs the list:
28
28
  * otherwise a node that announces a region could also send offers as it.
29
29
  */
30
+ import { ANNOUNCE_RETRY_MAX_MS, ANNOUNCE_RETRY_MS } from '../lobby.js';
30
31
  import { nodeHexOf } from '../store/host.js';
31
32
  import { joinStore } from '../store/join.js';
32
33
  /** Region `r:<rx>:<rz>` for a position, with regions `size` units square. */
@@ -51,8 +52,15 @@ export function regionsAround(x, z, options) {
51
52
  export function regionTag(world, region) {
52
53
  return `net-world:${world}:region:${region}`;
53
54
  }
54
- /** How often region hosts re-announce: an announcement is a lease. */
55
- export const REGION_ANNOUNCE_MS = 2_000;
55
+ /**
56
+ * How often region hosts re-announce: an announcement is a lease, held
57
+ * for the leaf's 300 s TTL. 30 s, not 2 s, for the reason
58
+ * `LOBBY_ANNOUNCE_MS` gives: re-announcements flood through the anchor to
59
+ * every peer, and at 2 s they saturated a host's anchor link at 64 nodes.
60
+ * A failed announcement is retried sooner, as a lobby's is
61
+ * (`ANNOUNCE_RETRY_MS`, doubling up to `ANNOUNCE_RETRY_MAX_MS`).
62
+ */
63
+ export const REGION_ANNOUNCE_MS = 30_000;
56
64
  /**
57
65
  * Announce that this node hosts `regions` of `world`, and keep announcing
58
66
  * (an announcement is a lease). `announce` replaces a node's tags, so pass
@@ -61,9 +69,29 @@ export const REGION_ANNOUNCE_MS = 2_000;
61
69
  export function announceRegions(node, world, regions, options = {}) {
62
70
  const tags = [...(options.tags ?? []), ...regions.map(region => regionTag(world, region))];
63
71
  let stopped = false;
72
+ // A failed announcement is retried on a short backoff of its own: a
73
+ // region nobody can find for a whole lease refresh is a hole in the map.
74
+ let retry = null;
75
+ let retryMs = ANNOUNCE_RETRY_MS;
64
76
  const once = () => {
65
- if (!stopped)
66
- void node.announce(tags).catch(() => undefined);
77
+ if (stopped)
78
+ return;
79
+ if (retry !== null) {
80
+ clearTimeout(retry);
81
+ retry = null;
82
+ }
83
+ void node.announce(tags).then(() => {
84
+ retryMs = ANNOUNCE_RETRY_MS;
85
+ }, () => {
86
+ if (stopped || retry !== null)
87
+ return;
88
+ retry = setTimeout(() => {
89
+ retry = null;
90
+ once();
91
+ }, retryMs);
92
+ retry.unref?.();
93
+ retryMs = Math.min(retryMs * 2, ANNOUNCE_RETRY_MAX_MS);
94
+ });
67
95
  };
68
96
  once();
69
97
  const timer = setInterval(once, options.everyMs ?? REGION_ANNOUNCE_MS);
@@ -71,6 +99,8 @@ export function announceRegions(node, world, regions, options = {}) {
71
99
  return () => {
72
100
  stopped = true;
73
101
  clearInterval(timer);
102
+ if (retry !== null)
103
+ clearTimeout(retry);
74
104
  };
75
105
  }
76
106
  /** A region directory over the mesh's signed announcements. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@net-mesh/browser",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "Browser-native Net mesh leaf \u2014 idiomatic TypeScript over the net-mesh-leaf wasm node",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -59,7 +59,8 @@
59
59
  "check:package": "node scripts/check-package.mjs",
60
60
  "test": "tsc -p tsconfig.test.json && vitest run",
61
61
  "test:watch": "vitest",
62
- "bench:world": "node scripts/bench-world.mjs"
62
+ "bench:world": "node scripts/bench-world.mjs",
63
+ "bench:netcode": "node scripts/bench-netcode.mjs"
63
64
  },
64
65
  "devDependencies": {
65
66
  "esbuild": "^0.28.0",