@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.
- package/CHANGELOG.md +24 -0
- package/README.md +12 -0
- package/dist/index.bundle.js +2 -2
- package/dist/leader/session.d.ts +4 -2
- package/dist/leader/session.js +30 -6
- package/dist/lobby.js +8 -4
- package/dist/net_leaf.d.ts +10 -10
- package/dist/net_leaf.js +35 -35
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/net_leaf_bg.wasm.d.ts +10 -10
- package/dist/node.d.ts +15 -0
- package/dist/node.js +75 -1
- package/dist/store/host.js +10 -4
- package/dist/store/join.js +38 -2
- package/dist/store/owner.d.ts +11 -0
- package/dist/store/owner.js +27 -7
- package/dist/store/types.d.ts +6 -0
- package/dist/stream.d.ts +29 -1
- package/dist/stream.js +66 -4
- package/dist/udp-probe.d.ts +21 -3
- package/dist/udp-probe.js +78 -7
- package/package.json +1 -1
package/dist/store/owner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/store/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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) {
|
package/dist/udp-probe.d.ts
CHANGED
|
@@ -53,7 +53,12 @@ export type StunProbeOutcome =
|
|
|
53
53
|
readonly type: 'unanswered';
|
|
54
54
|
readonly detail: string;
|
|
55
55
|
}
|
|
56
|
-
/**
|
|
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)
|
|
102
|
-
*
|
|
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)
|
|
65
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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.
|