@net-mesh/browser 0.42.0 → 0.43.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.
@@ -41,6 +41,15 @@ import { decimalValue, decodeMessage, encodeMessage, utf8Length, HANDLE_HEX_LENG
41
41
  export const MAX_PENDING_ACTIONS = 32;
42
42
  /** Handles one owner will hold (brief §2). */
43
43
  export const MAX_HANDLES = 256;
44
+ /**
45
+ * Handles one authenticated peer may hold at once. Room for siblings and
46
+ * for a reconnect that overlaps its predecessor's lease, but never the
47
+ * whole table: one peer could otherwise take all {@link MAX_HANDLES} and
48
+ * lock every other player out — the lobby's capacity rule counts distinct
49
+ * peers, so a seated player's repeat joins bypassed it (SDK audit
50
+ * 2026-10-11).
51
+ */
52
+ export const MAX_HANDLES_PER_PEER = 16;
44
53
  /** How long a handle lives without an accepted authenticated message. */
45
54
  export const HANDLE_LEASE_MS = 60_000;
46
55
  /** How many hook runs one drain may perform before it stops (see `drainEvents`). */
@@ -245,7 +254,7 @@ export class StoreOwner {
245
254
  // having been checked once at admission. The handle goes with the
246
255
  // refusal here because the feed is the grant: leaving it alive would
247
256
  // let `alive` renew the lease of a peer the policy now forbids.
248
- if (!this.permitsRead(handle.peer, handle.audience)) {
257
+ if (!this.permitsRead(handle.peer, handle.audience, handle.key)) {
249
258
  this.forget(handle.h, 'refused');
250
259
  // `closed`, the wire's legal unsolicited refusal (§1.12 admits
251
260
  // exactly `closed` and `owner-lost` without a `q`): it closes
@@ -505,7 +514,7 @@ export class StoreOwner {
505
514
  // must not be served because the handle is still warm. The
506
515
  // handle survives the refusal: the request was refused, not
507
516
  // the subscription.
508
- if (!this.permitsRead(peer, bound.audience)) {
517
+ if (!this.permitsRead(peer, bound.audience, bound.key)) {
509
518
  return this.refuse('resync-forbidden', [this.no(peer, bound.h, 'forbidden', message.q)]);
510
519
  }
511
520
  const renewed = { ...bound, lastSeen: now };
@@ -532,7 +541,7 @@ export class StoreOwner {
532
541
  // `audience-too-many` at rung 6 (wire.ts, witnessed in
533
542
  // wire.test.ts), so a frame carrying more than the bound never
534
543
  // reaches dispatch. Re-checking it would be a claim.
535
- if (!this.permitsRead(peer, message.aud)) {
544
+ if (!this.permitsRead(peer, message.aud, bound.key)) {
536
545
  return this.refuse(`${message.k}-forbidden`, [this.no(peer, bound.h, 'forbidden', message.q)]);
537
546
  }
538
547
  // The audience is rebound BEFORE the projection, so the
@@ -1000,7 +1009,7 @@ export class StoreOwner {
1000
1009
  reauthorize() {
1001
1010
  const out = [];
1002
1011
  for (const handle of [...this.handles.values()]) {
1003
- if (this.permitsRead(handle.peer, handle.audience))
1012
+ if (this.permitsRead(handle.peer, handle.audience, handle.key))
1004
1013
  continue;
1005
1014
  this.forget(handle.h, 'refused');
1006
1015
  out.push(this.no(handle.peer, handle.h, 'closed', null));
@@ -1152,9 +1161,16 @@ export class StoreOwner {
1152
1161
  if (this.handles.size >= MAX_HANDLES) {
1153
1162
  return this.refuse('join-capacity', [this.no(peer, null, 'capacity', message.q)]);
1154
1163
  }
1164
+ let held = 0;
1165
+ for (const existing of this.handles.values())
1166
+ if (existing.peer === peer)
1167
+ held += 1;
1168
+ if (held >= MAX_HANDLES_PER_PEER) {
1169
+ return this.refuse('join-capacity', [this.no(peer, null, 'capacity', message.q)]);
1170
+ }
1155
1171
  // `authorize` receives the AUTHENTICATED peer and the audience it
1156
1172
  // asked to read. A refusal is `forbidden` and allocates nothing.
1157
- if (!this.permitsRead(peer, message.aud)) {
1173
+ if (!this.permitsRead(peer, message.aud, message.key)) {
1158
1174
  return this.refuse('join-forbidden', [this.no(peer, null, 'forbidden', message.q)]);
1159
1175
  }
1160
1176
  const h = this.newHandle();
@@ -1166,6 +1182,7 @@ export class StoreOwner {
1166
1182
  inc: this.incarnation,
1167
1183
  peer,
1168
1184
  audience: [...message.aud],
1185
+ key: message.key,
1169
1186
  interest: message.int === undefined ? null : [...message.int],
1170
1187
  generation: 0,
1171
1188
  revision: this.core.revision,
@@ -1233,8 +1250,11 @@ export class StoreOwner {
1233
1250
  *
1234
1251
  * A throwing policy is a refusal, never an admission.
1235
1252
  */
1236
- permitsRead(peer, audience) {
1237
- const request = { type: 'read', peer, audience: [...audience] };
1253
+ permitsRead(peer, audience, key) {
1254
+ // `key` is the join's opaque key, which `joinStore({ key })` documents
1255
+ // as read by the host's policy; it was dropped here, so a key-gated
1256
+ // room refused everyone (SDK audit 2026-10-11).
1257
+ const request = (key === undefined ? { type: 'read', peer, audience: [...audience] } : { type: 'read', peer, audience: [...audience], key });
1238
1258
  try {
1239
1259
  return this.deps.authorize(request) === true;
1240
1260
  }
@@ -159,6 +159,12 @@ export type AccessRequest<A extends ActionSpec, I extends InputSpec> = {
159
159
  readonly type: 'read';
160
160
  readonly peer: string;
161
161
  readonly audience: readonly string[];
162
+ /**
163
+ * The opaque key the replica joined with (`joinStore({ key })`),
164
+ * carried on every later read decision for that subscription.
165
+ * Absent for the host's own player, which joins no wire.
166
+ */
167
+ readonly key?: string;
162
168
  } | {
163
169
  [K in keyof A]: {
164
170
  readonly type: 'action';
package/dist/stream.d.ts CHANGED
@@ -97,13 +97,24 @@ export interface OpenStreamOptions {
97
97
  */
98
98
  lossy?: boolean;
99
99
  }
100
+ /** Most frames a {@link LeafStream} buffers before its first consumer. */
101
+ export declare const LEAF_STREAM_PENDING_MAX_FRAMES = 1024;
102
+ /** Most bytes a {@link LeafStream} buffers before its first consumer. */
103
+ export declare const LEAF_STREAM_PENDING_MAX_BYTES: number;
100
104
  /**
101
105
  * One open stream.
102
106
  *
103
107
  * Inbound payloads are delivered to `for await` consumers and to
104
108
  * {@link LeafStream.onMessage} listeners. Payloads that arrive before
105
109
  * anyone consumes them are buffered in arrival order, so a page that
106
- * opens a stream and awaits it a tick later loses nothing.
110
+ * opens a stream and awaits it a tick later loses nothing — up to
111
+ * {@link LEAF_STREAM_PENDING_MAX_FRAMES} frames and
112
+ * {@link LEAF_STREAM_PENDING_MAX_BYTES} bytes. Past either bound the
113
+ * OLDEST buffered frame is dropped and counted in
114
+ * {@link LeafStream.droppedBeforeConsumer} (a single frame larger than the
115
+ * byte bound is never buffered, and is counted there too): a stream used only to send
116
+ * (the store, netcode and region-link layers read inbound traffic
117
+ * elsewhere) must not keep every frame the peer sends for its lifetime.
107
118
  */
108
119
  export declare class LeafStream implements AsyncIterable<Uint8Array> {
109
120
  private readonly inner;
@@ -111,7 +122,11 @@ export declare class LeafStream implements AsyncIterable<Uint8Array> {
111
122
  private readonly queues;
112
123
  private readonly listeners;
113
124
  private readonly pending;
125
+ private pendingBytes;
126
+ private dropped;
114
127
  private closed;
128
+ /** Why the SESSION closed this stream, when it did so for a leader change. */
129
+ private retiredBy;
115
130
  /**
116
131
  * This stream's wire id as a number, or `null` when the inner
117
132
  * object's `stream_id_hex()` was not hex.
@@ -144,6 +159,11 @@ export declare class LeafStream implements AsyncIterable<Uint8Array> {
144
159
  * either would fail the id comparison too, loudly.
145
160
  */
146
161
  private readonly peerId;
162
+ /**
163
+ * Frames dropped from the pre-consumer buffer because no `for await`
164
+ * consumer or `onMessage` listener was attached and it was full.
165
+ */
166
+ get droppedBeforeConsumer(): number;
147
167
  /** The authenticated peer this stream is with, 16 lowercase hex. */
148
168
  get peerNode(): string;
149
169
  /**
@@ -169,6 +189,14 @@ export declare class LeafStream implements AsyncIterable<Uint8Array> {
169
189
  * taken as an already-decoded payload, which is what a
170
190
  * host-supplied {@link LeafWasmStreamLike} may deliver.
171
191
  */
192
+ /**
193
+ * Hand the pre-consumer buffer over, emptying it — count included. The
194
+ * byte count stayed at the drained total, so after a consumer drained the
195
+ * buffer and detached, rebuffering tripped the byte cap almost at once
196
+ * and evicted all but the newest frame on nearly every arrival (code
197
+ * review 2026-10-11, BROWSER-1).
198
+ */
199
+ private drainPending;
172
200
  private receive;
173
201
  private decode;
174
202
  }
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@net-mesh/browser",
3
- "version": "0.42.0",
3
+ "version": "0.43.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",