fix(nat): close the browser NAT experiment loop (#370)

This commit is contained in:
AAA authored and GitHub committed 2026-09-01 23:42:36 +08:00
1 parent 4c081f4f88
commit b2f6d33630
37 files changed
+612 -67

No files matched your search

+5
View File
@@ -40,9 +40,14 @@ LIVEKIT_API_SECRET=
# Separate multiple URLs with commas.
STUN_URLS=
# Optional independent STUN observations used only when a Host enables the
# default-off NAT traversal experiment. Maximum two comma-separated URLs.
NAT_PREDICTION_STUN_URLS=
# Minimal production example (use independent untracked secrets):
# SITE_ACCESS_PASSWORD=replace-with-an-independent-access-key
# STUN_URLS=stun:stun.example.com:3478
# NAT_PREDICTION_STUN_URLS=stun:stun-observer.example.com:3478
# Automatic SFU fallback (requires PEER_ASSISTED_MEDIA=true and does not
# replace STUN above):
# LIVEKIT_URL=wss://share.example.com
@@ -12,9 +12,10 @@ receives a small set of adjacent server-reflexive candidates. The result is
network- and generation-specific; it is not a reliable NAT type or participant
capability.
The existing route-policy and ICE candidate owners are sufficient. Making the
experiment room-wide requires one strict protocol revision for the added policy
field, but no new message type, route-controller state, or media path.
The existing route-policy and ICE candidate owners are sufficient. The current
strict protocol carries the room policy, bounded independent observation URLs,
and anonymous selected-path provenance, but adds no NAT label, route-controller
state, or media path.
## Decision
@@ -25,17 +26,25 @@ field, but no new message type, route-controller state, or media path.
2. When enabled, every Browser P2P connection adds ports 3479 and 3480 for the
same STUN authority already supplied by the server. This covers Host direct,
Viewer upstream, and Viewer relay connections. SFU PeerConnections remain
unchanged.
unchanged. A deployment may also provide at most two independent `stun:`
observation URLs. They are added only inside the enabled experiment and are
never part of ordinary room ICE.
3. For one ICE gathering generation, the adapter observes only UDP `srflx`
candidates. If three or more distinct candidates for one media section and
public address form an arithmetic port sequence, it appends at most eight
bounded candidates around the sequence's high endpoint (four in each
direction). Every ordinary candidate keeps normal Trickle ICE timing; no
ordinary candidate is delayed, rejected, or replaced. A failed or
inconclusive experiment uses the existing ICE and SFU fallback unchanged.
candidates whose Browser-reported STUN URL belongs to the self-hosted
3478/3479/3480 survey set. Independent observations remain ordinary ICE
candidates and cannot enter the arithmetic sequence. If three or more
distinct survey candidates for one media section and public address form an
arithmetic port sequence, the adapter appends at most eight bounded
candidates around the sequence's high endpoint. Every ordinary candidate
keeps normal Trickle ICE timing; no ordinary candidate is delayed, rejected,
or replaced. A Browser that does not expose candidate source URLs keeps
ordinary ICE but does not mix independent observations into prediction.
4. Candidate observations remain in memory for that connection only. No NAT
label, raw address, port, score, hard candidate skip, periodic probe, or
route-controller branch is introduced.
route-controller branch is introduced. Sanitized diagnostics record only
whether a signaled candidate was ordinary, predicted, or end-of-candidates,
and whether the selected remote foundation was ordinary, predicted, or
unknown.
## Consequences
@@ -47,12 +56,16 @@ Positive:
listener or unsuccessful prediction cannot delay the existing direct or SFU
fallback; and
- the experiment is bounded to one connection generation and one small
candidate set.
candidate set; and
- selected-foundation attribution can validate actual field benefit without
uploading candidate endpoints.
Negative:
- the Browser may still reject synthetic candidates or the NAT may not have a
matching mapping; and
matching mapping;
- every independent STUN operator observes connection metadata and supplies no
availability promise unless its own service contract says otherwise; and
- two endpoint-dependent NATs remain sensitive to candidate scheduling and
intervening mappings, so room-wide coverage is not a success guarantee.
+7
View File
@@ -99,6 +99,13 @@ These same-IP auxiliary ports measure destination-port mapping behavior. They
are not a full RFC 5780 alternate-address deployment; that requires a second
public IPv4.
An operator may configure at most two independent observation URLs through
`NAT_PREDICTION_STUN_URLS`. They are contacted only by Browser P2P connections
in an opted-in room, remain ordinary ICE candidates, and require no local
listener or firewall change. Verify them with a real Browser from the target
network before use. DNS resolution alone is not a STUN availability check, and
an unaffiliated public endpoint has no implied SLA.
## Operational Verification
- `/healthz` returns `{"status":"ok"}` locally and through the public origin.
+5 -2
View File
@@ -34,8 +34,11 @@ share generation:
availability routing is unchanged;
- the default-off NAT traversal experiment augments direct ICE candidates on
every Browser P2P edge in that share. It is not a participant capability,
route score, or SFU preference. [ADR-0009](../adr/0009-connection-local-nat-prediction-experiment.md)
owns its bounded behavior and evidence boundary.
route score, or SFU preference. A deployment may add a bounded independent
STUN observation list only inside that experiment. Candidate generation and
selected-pair diagnostics retain only `ordinary | predicted | unknown`
provenance. [ADR-0009](../adr/0009-connection-local-nat-prediction-experiment.md)
owns the bounded behavior and evidence boundary.
These are route-policy gates, not new routing algorithms. Changing any
policy requires stopping the current share and starting another generation.
+5 -3
View File
@@ -31,8 +31,10 @@ implementation and routine UI detail.
participants, routes, and media remain process-only.
- Media is automatic and distributed. Ordinary peers are STUN-only and prefer
direct/peer UDP. Host may opt the current share into a bounded, room-wide,
connection-local NAT candidate experiment; the default path remains stock
ICE. The only application fallback is one bounded LiveKit SFU/UDP
connection-local NAT candidate experiment; a deployment may add two
independent observation STUN URLs only inside that experiment, and anonymous
provenance distinguishes ordinary from predicted selected paths. The default
path remains stock ICE. The only application fallback is one bounded LiveKit SFU/UDP
publication; Screener configures no TURN or TCP media route. A pre-share
peer-only policy can exclude that SFU suffix for one share generation.
- Every endpoint shares one steady outbound-copy cap, default `2` and limited to
@@ -57,7 +59,7 @@ implementation and routine UI detail.
## Current Snapshot
Canonical source uses the strict `screener-v18` Browser/server contract with
Canonical source uses the strict `screener-v19` Browser/server contract with
committed media readiness, bounded candidate-relative progress and one-shot
Host-root convergence. It retains optional SQLite room authority and the
H.264/VP8 sender gate. [Status](./status.md) owns the compact current execution
+6
View File
@@ -20,6 +20,7 @@ service secret store or an untracked access-restricted environment file.
| `PEER_ASSISTED_MEDIA` | Enables the all-room bounded Peer/SFU controller. |
| `ENDPOINT_MEDIA_COPY_CAPACITY` | Shared endpoint steady-copy cap `1..3`, default `2`. |
| `STUN_URLS` | Comma-separated `stun:` URLs; at least one is required in production. |
| `NAT_PREDICTION_STUN_URLS` | Optional comma-separated independent `stun:` observations, maximum `2`; used only by rooms that enable the default-off NAT experiment. |
Automatic SFU fallback is enabled only when `PEER_ASSISTED_MEDIA=true` and all
four values below are present:
@@ -68,6 +69,11 @@ a cloud port without a listener has no effect. No new DNS record is required.
Keep both listeners available while the experiment is offered and remove them
only after disabling it. Same-IP ports expose destination-port allocation
behavior; a full RFC 5780 alternate-address test requires a second public IPv4.
Optional `NAT_PREDICTION_STUN_URLS` add independent ordinary candidates without
changing `STUN_URLS`. Their operators receive client address, port, time, and
protocol metadata when the Host enables the experiment. They need no inbound
server port or DNS change, but should be omitted when that dependency is not
accepted.
## Bounds
+18
View File
@@ -71,6 +71,23 @@ locks while sharing, and applies to Host, Viewer upstream, and Viewer relay P2P
connections. Its exact scope is recorded in
[ADR-0009](../adr/0009-connection-local-nat-prediction-experiment.md).
### Independent Observation And Attribution Preflight
On 2026-09-01, Chrome 151 on the flagship Windows network gathered one `srflx`
candidate independently from the self-hosted endpoint, Xiaomi, Bilibili, and
Cloudflare within a five-second bound. `stun.qq.com:3478` produced none in the
same ordered run. Candidate events and local candidate stats preserved the exact
STUN `url`, so independent destinations can remain ordinary candidates while
only self-hosted 3478/3479/3480 observations feed prediction. These are measured
reachability results, not third-party availability commitments.
A second real-Chrome loopback rewrote only one remote candidate foundation to
`sp1`, established the data channel, and read `sp1` from the selected remote
candidate stats. The product can therefore report `predicted | ordinary |
unknown` selection without retaining or uploading addresses, ports, foundations,
or STUN URLs. Field acceptance still requires target-network outcomes; this
preflight proves the attribution mechanism, not prediction prevalence.
The Browser-only alternatives do not yet have an accepted implementation:
- WebRTC ICE already exchanges observed candidates and performs coordinated
@@ -148,3 +165,4 @@ a new Peer route.
- [libp2p Browser and DCUtR hole-punching boundary](https://github.com/libp2p/specs/blob/master/connections/hole-punching.md)
- [coturn listener and auxiliary endpoint reference](https://github.com/coturn/coturn/blob/master/examples/etc/turnserver.conf)
- [Cloudflare Realtime STUN service](https://developers.cloudflare.com/realtime/turn/)
- [WebRTC selected candidate stats](https://www.w3.org/TR/webrtc-stats/#dom-rtcicecandidatestats-foundation)
+9 -6
View File
@@ -7,7 +7,7 @@ This is the current execution index. Git history owns completed timelines;
## Production
- `https://share.bonfire.icu` runs the strict `screener-v18` Browser/server
- `https://share.bonfire.icu` runs the strict `screener-v19` Browser/server
contract. Exact revision, release, artifact, manifest, and asset identity are
retained by the immutable release descriptor, runtime `REVISION`, and
deployment record rather than copied into this source snapshot.
@@ -23,9 +23,9 @@ This is the current execution index. Git history owns completed timelines;
- Site access uses a stateless 24-hour rolling idle cookie. An active
site-authorized page renews it hourly through the existing status request;
Viewer-grant admission remains independent and cannot create or renew it.
- Public media listeners remain STUN-only UDP 3478 and LiveKit UDP 7882. During
the approved NAT survey, coturn also exposes auxiliary STUN-only UDP 3479
and 3480; ordinary `STUN_URLS` and media routes remain unchanged. Web ingress
- Public media listeners remain STUN-only UDP 3478 and LiveKit UDP 7882. The
optional NAT experiment also uses auxiliary STUN-only UDP 3479 and 3480;
ordinary `STUN_URLS` and media routes remain unchanged. Web ingress
is TCP 80/443; Node 8787 and LiveKit control/signaling 7880 are private. TURN,
ICE/TCP, media TCP, TLS relay, port 5349, and relay ranges are disabled.
- Browser video uses the content-independent H.264 sender gate with VP8
@@ -49,7 +49,7 @@ This is the current execution index. Git history owns completed timelines;
## Current Source
- Canonical source uses the strict `screener-v18` Browser/server contract.
- Canonical source uses the strict `screener-v19` Browser/server contract.
Optional SQLite stable authority, non-expiring local preferred code, atomic
room replacement and the Host codec selector remain implemented; production
selects stable storage. Graceful restart, crash, timeout and network loss use
@@ -77,7 +77,10 @@ This is the current execution index. Git history owns completed timelines;
defaults on and locks while sharing.
- Host Advanced settings include a default-off, per-share NAT traversal
experiment. Every Browser P2P role uses the same bounded, connection-local
candidate adapter; ordinary candidates and SFU fallback remain unchanged.
candidate adapter. At most two deployment-supplied independent STUN
observations are contacted only inside opted-in rooms; candidate and selected
path logs retain anonymous `ordinary | predicted | unknown` provenance.
Ordinary candidates and SFU fallback remain unchanged.
- Every direct, Browser-relay, and Host SFU video sender owns one clone of its
source track; the original remains presentation and source authority only.
Replacement, rollback, unpublish, physical LiveKit sender recreation, and
+3 -2
View File
@@ -86,8 +86,9 @@ until their stated boundary is met.
[ADR-0009](./adr/0009-connection-local-nat-prediction-experiment.md). Keep
multi-STUN observations and injected candidates additive and diagnostic;
do not make them a participant-wide NAT label, hard candidate skip, route
score, or SFU preference. Validate target-network success, candidate
direction, and bounded resource impact before enabling it by default.
score, or SFU preference. Candidate generation and selected-path provenance
are now observable without endpoint data. Validate target-network success,
candidate direction, and bounded resource impact before enabling it by default.
15. **Unresolved route-state ownership claims.** Reopen lower-revision
reauthentication, active SFU failure during an unrelated prepare, and
multi-child relay-evidence ownership only from an exact current-wire
+1 -1
View File
@@ -99,7 +99,7 @@ async function main(): Promise<void> {
publicBaseUrl: new URL(baseUrl), allowedOrigins: new Set([baseUrl]),
siteAccessPassword: sitePassword, roomLeaseMs: 86_400_000,
maxViewersPerRoom: 2, endpointMediaCopyCapacity: 2,
peerAssistedMedia: false, stunUrls: [],
peerAssistedMedia: false, stunUrls: [], natPredictionStunUrls: [],
};
server = await createScreenerServer({ config, staticDirectory: resolve(root, "dist/client") });
server.httpServer.prependListener("request", (request, response) => {
@@ -102,6 +102,7 @@ export function senderQualityEvidenceFromSnapshot(
routeRevision,
state,
diagnostics: {
natTraversalPath: snapshot.metrics.natTraversalPath,
reason: state === "unknown" ? null : snapshot.metrics.qualityLimitationReason,
framesPerSecond: snapshot.metrics.framesPerSecond,
bitrateKbps: snapshot.metrics.bitrateKbps,
@@ -166,6 +167,7 @@ export function sfuPublisherQualityEvidenceFromMetrics(
state,
sampleTimestampMs,
diagnostics: {
natTraversalPath: metrics.natTraversalPath,
reason: state === "unknown" ? null : metrics.qualityLimitationReason,
framesPerSecond: metrics.framesPerSecond,
bitrateKbps: metrics.bitrateKbps,
@@ -97,6 +97,7 @@ export function qualityEvidenceWindowFromMetrics(
? { width, height }
: { width: null, height: null };
const candidate: ViewerQualityEvidenceMetrics = {
natTraversalPath: metrics.natTraversalPath,
...dimensions,
framesPerSecond: boundedNumber(metrics.framesPerSecond, 240),
bitrateKbps: boundedNumber(metrics.bitrateKbps, 100_000),
@@ -502,6 +503,7 @@ export function metricsFromQualityEvidence(
const metrics = evidence.metrics;
return {
...EMPTY_METRICS,
natTraversalPath: metrics.natTraversalPath,
sampleWindowMs: evidence.windowMs,
framesPerSecond: metrics.framesPerSecond,
frameWidth: metrics.width,
+4
View File
@@ -1,3 +1,5 @@
import type { NatTraversalPath } from "../shared/nat-candidate";
export type SignalConnectionState =
| "connecting"
| "connected"
@@ -28,6 +30,7 @@ export interface ConnectionMetrics {
captureFramesPerSecond: number | null;
mediaSourceFramesPerSecond: number | null;
path: MediaPath;
natTraversalPath: NatTraversalPath;
iceProtocol: string | null;
localCandidateType: string | null;
remoteCandidateType: string | null;
@@ -114,6 +117,7 @@ export const EMPTY_METRICS: ConnectionMetrics = {
captureFramesPerSecond: null,
mediaSourceFramesPerSecond: null,
path: "unknown",
natTraversalPath: "unknown",
iceProtocol: null,
localCandidateType: null,
remoteCandidateType: null,
+14 -1
View File
@@ -39,12 +39,15 @@ import {
} from "./video-codec";
import {
iceServersWithNatPrediction,
natPredictionSurveyUrls,
NatPredictionCandidateEmitter,
type SignalCandidate,
} from "./nat-prediction";
const MAX_PENDING_CANDIDATES = 64;
type PeerIceConfig = Pick<RTCConfiguration, "iceServers">;
type PeerIceConfig = Pick<RTCConfiguration, "iceServers"> & {
natPredictionStunUrls?: readonly string[];
};
interface HostPeerEvents {
sendSignal: (peerId: string, payload: SignalPayload) => boolean;
@@ -111,11 +114,15 @@ export class HostPeer {
iceServers: iceServersWithNatPrediction(
iceConfig.iceServers,
this.natPredictionEnabled,
iceConfig.natPredictionStunUrls,
),
});
this.localIceCandidates = new NatPredictionCandidateEmitter(
this.natPredictionEnabled,
(candidate) => this.sendIceCandidate(candidate),
natPredictionSurveyUrls(
iceConfig.iceServers,
),
);
this.snapshot = {
peerId,
@@ -414,8 +421,14 @@ export class HostPeer {
iceServers: iceServersWithNatPrediction(
iceConfig.iceServers,
this.natPredictionEnabled,
iceConfig.natPredictionStunUrls,
),
});
this.localIceCandidates.setSurveyUrls(
natPredictionSurveyUrls(
iceConfig.iceServers,
),
);
} catch (error) {
this.setError(error, say("host.err.createConnection"));
}
+99 -8
View File
@@ -1,4 +1,7 @@
import type { SignalPayload } from "../../shared/protocol";
import {
MAX_NAT_PREDICTION_STUN_URLS,
type SignalPayload,
} from "../../shared/protocol";
export type SignalCandidate = Extract<
SignalPayload,
@@ -69,6 +72,57 @@ function auxiliaryUrlsFor(url: string): readonly string[] {
);
}
function normalizedStunUrl(url: string): string | null {
if (!/^stun:/i.test(url)) {
return null;
}
const authority = url.slice(url.indexOf(":") + 1);
let parsed: URL;
try {
parsed = new URL(`http://${authority}`);
} catch {
return null;
}
if (
!parsed.hostname ||
parsed.username ||
parsed.password ||
parsed.pathname !== "/" ||
parsed.search ||
parsed.hash
) {
return null;
}
const hostname = parsed.hostname.startsWith("[")
? parsed.hostname.toLowerCase()
: parsed.hostname.includes(":")
? `[${parsed.hostname.toLowerCase()}]`
: parsed.hostname.toLowerCase();
return `stun:${hostname}:${parsed.port || BASE_STUN_PORT}`;
}
/** URLs whose same-socket observations may form the controlled port sequence. */
export function natPredictionSurveyUrls(
iceServers: readonly RTCIceServer[] | undefined,
): ReadonlySet<string> {
for (const server of iceServers ?? []) {
for (const url of urlsOf(server)) {
const auxiliaryUrls = auxiliaryUrlsFor(url);
const baseUrl = normalizedStunUrl(url);
if (baseUrl && auxiliaryUrls.length > 0) {
return new Set([
baseUrl,
...auxiliaryUrls.flatMap((entry) => {
const normalized = normalizedStunUrl(entry);
return normalized ? [normalized] : [];
}),
]);
}
}
}
return new Set();
}
/**
* Add the two optional same-host survey listeners without changing the
* server-provided STUN list. The caller opts into this per connection.
@@ -76,6 +130,7 @@ function auxiliaryUrlsFor(url: string): readonly string[] {
export function iceServersWithNatPrediction(
iceServers: readonly RTCIceServer[] | undefined,
enabled: boolean,
externalStunUrls: readonly string[] = [],
): RTCIceServer[] {
const base = (iceServers ?? []).map(cloneIceServer);
if (!enabled) {
@@ -84,13 +139,17 @@ export function iceServersWithNatPrediction(
const additions = new Set<string>();
const existing = new Set(
(iceServers ?? []).flatMap((server) => urlsOf(server)),
(iceServers ?? [])
.flatMap((server) => urlsOf(server))
.map((url) => normalizedStunUrl(url) ?? url),
);
for (const server of iceServers ?? []) {
for (const url of urlsOf(server)) {
for (const auxiliaryUrl of auxiliaryUrlsFor(url)) {
if (!existing.has(auxiliaryUrl)) {
const normalized = normalizedStunUrl(auxiliaryUrl) ?? auxiliaryUrl;
if (!existing.has(normalized)) {
additions.add(auxiliaryUrl);
existing.add(normalized);
}
}
if (additions.size >= AUXILIARY_STUN_PORTS.length) {
@@ -102,6 +161,14 @@ export function iceServersWithNatPrediction(
}
}
for (const url of externalStunUrls.slice(0, MAX_NAT_PREDICTION_STUN_URLS)) {
const normalized = normalizedStunUrl(url);
if (normalized && !existing.has(normalized)) {
additions.add(url);
existing.add(normalized);
}
}
return [
...base,
...Array.from(additions, (url) => ({ urls: url })),
@@ -255,7 +322,7 @@ export class NatPredictionCandidateBatch {
constructor(private readonly send: (candidate: SignalCandidate | null) => void) {}
add(candidate: SignalCandidate): void {
add(candidate: SignalCandidate, predictionEligible = true): void {
if (this.completed) {
return;
}
@@ -263,8 +330,10 @@ export class NatPredictionCandidateBatch {
this.send(candidate);
return;
}
this.observedSrflx.push(candidate);
this.trySendPredictions();
if (predictionEligible) {
this.observedSrflx.push(candidate);
this.trySendPredictions();
}
this.send(candidate);
}
@@ -311,11 +380,24 @@ export class NatPredictionCandidateEmitter {
private batch: NatPredictionCandidateBatch | null = null;
private usernameFragment: string | null = null;
private endSent = false;
private surveyUrls = new Set<string>();
constructor(
private readonly enabled: boolean,
private readonly send: (candidate: SignalCandidate | null) => void,
) {}
surveyUrls: ReadonlySet<string> = new Set(),
) {
this.setSurveyUrls(surveyUrls);
}
setSurveyUrls(urls: ReadonlySet<string>): void {
this.surveyUrls = new Set(
[...urls].flatMap((url) => {
const normalized = normalizedStunUrl(url);
return normalized ? [normalized] : [];
}),
);
}
add(candidate: RTCIceCandidate | null): void {
if (!this.enabled) {
@@ -345,7 +427,16 @@ export class NatPredictionCandidateEmitter {
this.endSent = false;
this.batch = new NatPredictionCandidateBatch(this.send);
}
this.batch.add(next);
const serverUrl = (
candidate as RTCIceCandidate & { readonly url?: string | null }
).url;
const candidateUrl = serverUrl
? normalizedStunUrl(serverUrl)
: null;
this.batch.add(
next,
candidateUrl !== null && this.surveyUrls.has(candidateUrl),
);
}
gatheringComplete(): void {
+9
View File
@@ -4,6 +4,7 @@ import {
} from "../types";
import { packetLossPercentFromDeltas } from "../../shared/packet-loss";
import { deriveVideoCodecEvidence } from "../../shared/video-codec-evidence";
import { isPredictedCandidateFoundation } from "../../shared/nat-candidate";
type StatsRecord = Record<string, unknown> & {
id: string;
@@ -675,6 +676,13 @@ export function collectConnectionMetricsFromReport(
}
const localType = stringValue(localCandidate, "candidateType");
const remoteType = stringValue(remoteCandidate, "candidateType");
const remoteFoundation = stringValue(remoteCandidate, "foundation");
const natTraversalPath =
remoteFoundation === null
? "unknown"
: isPredictedCandidateFoundation(remoteFoundation)
? "predicted"
: "ordinary";
const path =
localType === "relay" || remoteType === "relay"
? "unknown"
@@ -1068,6 +1076,7 @@ export function collectConnectionMetricsFromReport(
trackIdentifier,
selectedCandidatePairId: pair?.id ?? null,
path,
natTraversalPath,
iceProtocol,
localCandidateType: localType,
remoteCandidateType: remoteType,
+14 -1
View File
@@ -14,6 +14,7 @@ import { preferScreenAudioStereo } from "./screen-audio-sdp";
import { observeDecodedFrameProof } from "../media/decoded-frame-proof";
import {
iceServersWithNatPrediction,
natPredictionSurveyUrls,
NatPredictionCandidateEmitter,
} from "./nat-prediction";
@@ -21,7 +22,9 @@ const MAX_PENDING_CANDIDATES = 64;
const MAX_AUTOMATIC_RECOVERY_REQUESTS = 2;
const INITIAL_CONNECTION_TIMEOUT_MS = 15_000;
const AUTOMATIC_RECOVERY_TIMEOUT_MS = 3_000;
type PeerIceConfig = Pick<RTCConfiguration, "iceServers">;
type PeerIceConfig = Pick<RTCConfiguration, "iceServers"> & {
natPredictionStunUrls?: readonly string[];
};
type SignalCandidate = Extract<
SignalPayload,
{ kind: "candidate" }
@@ -124,8 +127,14 @@ export class ViewerPeer {
iceServers: iceServersWithNatPrediction(
iceConfig.iceServers,
this.natPredictionEnabled,
iceConfig.natPredictionStunUrls,
),
});
this.localIceCandidates?.setSurveyUrls(
natPredictionSurveyUrls(
iceConfig.iceServers,
),
);
} catch (error) {
this.setError(error, say("host.fail.connection"));
}
@@ -225,6 +234,7 @@ export class ViewerPeer {
iceServers: iceServersWithNatPrediction(
this.currentIceConfig.iceServers,
this.natPredictionEnabled,
this.currentIceConfig.natPredictionStunUrls,
),
});
const localIceCandidates = new NatPredictionCandidateEmitter(
@@ -239,6 +249,9 @@ export class ViewerPeer {
candidate,
});
},
natPredictionSurveyUrls(
this.currentIceConfig.iceServers,
),
);
this.connection = connection;
this.localIceCandidates = localIceCandidates;
+1
View File
@@ -105,6 +105,7 @@ export async function createScreenerServer(
});
const iceOptions = {
stunUrls: config.stunUrls,
natPredictionStunUrls: config.natPredictionStunUrls,
};
let frontendHandler: FrontendHandler | undefined;
+24 -5
View File
@@ -2,6 +2,7 @@ import { isAbsolute } from "node:path";
import {
MAX_ICE_SERVER_URLS,
MAX_NAT_PREDICTION_STUN_URLS,
MAX_VIEWERS_PER_ROOM_LIMIT,
stunUrlSchema,
} from "../shared/protocol.js";
@@ -56,6 +57,7 @@ export interface ServerConfig {
endpointMediaCopyCapacity: number;
livekitFallback?: LiveKitFallbackConfig;
stunUrls: readonly string[];
natPredictionStunUrls: readonly string[];
}
function parseBoolean(
@@ -228,11 +230,14 @@ function parseUrlList(value: string | undefined, name: string): string[] {
});
}
function parseStunUrlList(value: string | undefined): string[] {
const name = "STUN_URLS";
function parseStunUrlList(
value: string | undefined,
name: string,
maximum = MAX_ICE_SERVER_URLS,
): string[] {
const values = parseUrlList(value, name);
if (values.length > MAX_ICE_SERVER_URLS) {
throw new Error(`${name} must contain at most ${MAX_ICE_SERVER_URLS} URLs`);
if (values.length > maximum) {
throw new Error(`${name} must contain at most ${maximum} URLs`);
}
return values.map((value) => {
if (!stunUrlSchema.safeParse(value).success) {
@@ -317,7 +322,20 @@ export function loadConfig(
const roomDatabasePath = parseRoomDatabasePath(
environment.ROOM_DATABASE_PATH,
);
const stunUrls = parseStunUrlList(environment.STUN_URLS);
const stunUrls = parseStunUrlList(environment.STUN_URLS, "STUN_URLS");
const natPredictionStunUrls = parseStunUrlList(
environment.NAT_PREDICTION_STUN_URLS,
"NAT_PREDICTION_STUN_URLS",
MAX_NAT_PREDICTION_STUN_URLS,
);
if (
stunUrls.length + natPredictionStunUrls.length >
MAX_ICE_SERVER_URLS
) {
throw new Error(
`STUN_URLS and NAT_PREDICTION_STUN_URLS must contain at most ${MAX_ICE_SERVER_URLS} URLs together`,
);
}
const maxViewersPerRoom = parseBoundedInteger(
environment.MAX_VIEWERS_PER_ROOM,
DEFAULT_MAX_VIEWERS_PER_ROOM,
@@ -392,5 +410,6 @@ export function loadConfig(
endpointMediaCopyCapacity,
livekitFallback,
stunUrls,
natPredictionStunUrls,
};
}
+5
View File
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
import { debuglog } from "node:util";
import { packetLossPercentFromDeltas } from "../shared/packet-loss.js";
import type { CandidateSignalOrigin } from "../shared/nat-candidate.js";
import type {
ClientMessage,
@@ -324,6 +325,7 @@ export class HybridMediaRouter {
input.upstream.kind === "peer"
? `p2p:${this.debugPeer(roomId, input.upstream.peerId)}`
: "sfu",
natTraversalPath: input.metrics.natTraversalPath,
framesPerSecond: input.metrics.framesPerSecond,
bitrateKbps: input.metrics.bitrateKbps,
width: input.metrics.width,
@@ -397,6 +399,7 @@ export class HybridMediaRouter {
routeRevision: message.routeRevision,
...copyContext,
state: message.state,
natTraversalPath: message.diagnostics.natTraversalPath,
reason: message.diagnostics.reason,
framesPerSecond: message.diagnostics.framesPerSecond,
bitrateKbps: message.diagnostics.bitrateKbps,
@@ -683,6 +686,7 @@ export class HybridMediaRouter {
sourcePeerId: string;
targetPeerId: string;
signalKind: "candidate" | "description";
candidateOrigin?: CandidateSignalOrigin;
descriptionType?: "offer" | "answer";
authorization: boolean | "probe" | undefined;
}): void {
@@ -690,6 +694,7 @@ export class HybridMediaRouter {
source: this.debugPeer(input.roomId, input.sourcePeerId),
target: this.debugPeer(input.roomId, input.targetPeerId),
signalKind: input.signalKind,
candidateOrigin: input.candidateOrigin ?? null,
descriptionType: input.descriptionType ?? null,
authorization:
input.authorization === undefined ? "assignment" : input.authorization,
+4 -2
View File
@@ -1,12 +1,14 @@
import type { IceConfig } from "../shared/protocol.js";
import type { WireIceConfig } from "../shared/protocol.js";
export interface IceConfigOptions {
stunUrls: readonly string[];
natPredictionStunUrls?: readonly string[];
}
export function createIceConfig(options: IceConfigOptions): IceConfig {
export function createIceConfig(options: IceConfigOptions): WireIceConfig {
return {
iceServers:
options.stunUrls.length > 0 ? [{ urls: [...options.stunUrls] }] : [],
natPredictionStunUrls: [...(options.natPredictionStunUrls ?? [])],
};
}
+8
View File
@@ -25,6 +25,7 @@ import {
type ServerMessage,
type ParticipantPresenceEntry,
} from "../shared/protocol.js";
import { candidateSignalOrigin } from "../shared/nat-candidate.js";
import { assertEndpointMediaCopyCapacity } from "../shared/media-copy-accounting.js";
import {
RoomStore,
@@ -1534,6 +1535,13 @@ export class SignalingServer {
sourcePeerId: source.peerId,
targetPeerId: target.peerId,
signalKind: message.payload.kind,
...(message.payload.kind === "candidate"
? {
candidateOrigin: candidateSignalOrigin(
message.payload.candidate?.candidate ?? null,
),
}
: {}),
...(description ? { descriptionType: description.type } : {}),
authorization: candidateAuthorized,
});
+25
View File
@@ -0,0 +1,25 @@
export const NAT_TRAVERSAL_PATHS = [
"unknown",
"ordinary",
"predicted",
] as const;
export type NatTraversalPath = (typeof NAT_TRAVERSAL_PATHS)[number];
export type CandidateSignalOrigin = "end" | "ordinary" | "predicted";
export function isPredictedCandidateFoundation(
foundation: string | null,
): boolean {
return foundation !== null && /^s[pm]\d+$/.test(foundation);
}
export function candidateSignalOrigin(
candidate: string | null,
): CandidateSignalOrigin {
if (candidate === null || candidate.trim() === "") {
return "end";
}
return /^candidate:s[pm]\d+\s/i.test(candidate)
? "predicted"
: "ordinary";
}
+13 -2
View File
@@ -2,11 +2,12 @@ import { z } from "zod";
import { MAX_ENDPOINT_MEDIA_COPY_CAPACITY } from "./media-copy-accounting.js";
import { isCanonicalVideoCodecEvidence } from "./video-codec-evidence.js";
import { NAT_TRAVERSAL_PATHS } from "./nat-candidate.js";
export const MAX_VIEWERS_PER_ROOM_LIMIT = 20;
export const MAX_PARTICIPANTS_PER_ROOM_LIMIT = MAX_VIEWERS_PER_ROOM_LIMIT + 1;
export const MAX_SIGNAL_BYTES = 64 * 1024;
export const SIGNALING_PROTOCOL = "screener-v18";
export const SIGNALING_PROTOCOL = "screener-v19";
export const SIGNAL_CLOSE_CODES = {
serviceRestart: 1012,
sessionReplaced: 4001,
@@ -18,6 +19,7 @@ export const ROOM_CODE_LENGTH = 4;
export const MAX_MEDIA_ROUTE_REVISION = Number.MAX_SAFE_INTEGER;
export const MAX_SFU_TOKEN_LENGTH = 8 * 1024;
export const MAX_ICE_SERVER_URLS = 8;
export const MAX_NAT_PREDICTION_STUN_URLS = 2;
export const MAX_VIEWER_QUALITY_EVIDENCE_BYTES = 2 * 1024;
export const VIEWER_QUALITY_EVIDENCE_INTERVAL_MS = 2_000;
export const VIEWER_QUALITY_EVIDENCE_EXPIRY_MS = 5_000;
@@ -288,9 +290,16 @@ const iceServerSchema = z
export const iceConfigSchema = z
.object({
iceServers: z.array(iceServerSchema).max(8),
natPredictionStunUrls: z
.array(stunUrlSchema)
.max(MAX_NAT_PREDICTION_STUN_URLS),
})
.strict();
export type IceConfig = z.infer<typeof iceConfigSchema>;
export type WireIceConfig = z.infer<typeof iceConfigSchema>;
export type IceConfig = {
iceServers: Array<z.infer<typeof iceServerSchema>>;
natPredictionStunUrls?: string[];
};
const sessionDescriptionSchema = z
.object({
@@ -565,6 +574,7 @@ const nullableSafeEvidenceInteger = z
export const viewerQualityEvidenceMetricsSchema = z
.object({
natTraversalPath: z.enum(NAT_TRAVERSAL_PATHS),
width: z.number().int().min(1).max(16_384).nullable(),
height: z.number().int().min(1).max(16_384).nullable(),
framesPerSecond: nullableEvidenceNumber(240),
@@ -644,6 +654,7 @@ const viewerQualityEvidenceWindowShape = {
const senderQualityDiagnosticsSchema = z
.object({
natTraversalPath: z.enum(NAT_TRAVERSAL_PATHS),
reason: z.enum(["none", "bandwidth", "cpu"]).nullable(),
framesPerSecond: nullableEvidenceNumber(240),
bitrateKbps: nullableEvidenceNumber(100_000),
+15 -7
View File
@@ -864,7 +864,7 @@ describe("client signaling recovery policy", () => {
hostOnline: true,
connectionId: null,
viewerPeerIds: [],
iceConfig: { iceServers: [] },
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
codeEntryPolicy: "open",
viewerAuthorizationGeneration: "viewer_generation_12345678",
}),
@@ -1008,7 +1008,7 @@ describe("client signaling recovery policy", () => {
hostPaused: true,
connectionId: null,
viewerPeerIds: [],
iceConfig: { iceServers: [] },
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
codeEntryPolicy: "open",
viewerPasswordEnabled: false,
viewerAuthorizationGeneration: "viewer_generation_12345678",
@@ -1044,7 +1044,7 @@ describe("client signaling recovery policy", () => {
hostPaused: true,
connectionId: null,
viewerPeerIds: [],
iceConfig: { iceServers: [] },
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
codeEntryPolicy: "open",
viewerPasswordEnabled: false,
viewerAuthorizationGeneration: "viewer_generation_12345678",
@@ -1157,7 +1157,7 @@ describe("client signaling recovery policy", () => {
hostPaused: false,
connectionId: null,
viewerPeerIds: [],
iceConfig: { iceServers: [] },
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
codeEntryPolicy: "open",
viewerPasswordEnabled: false,
viewerAuthorizationGeneration: "viewer_generation_12345678",
@@ -1285,7 +1285,7 @@ describe("client signaling recovery policy", () => {
hostOnline: true,
connectionId: null,
viewerPeerIds: [],
iceConfig: { iceServers: [] },
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
codeEntryPolicy: "open",
viewerPasswordEnabled: false,
viewerAuthorizationGeneration: "viewer_generation_12345678",
@@ -1402,7 +1402,7 @@ describe("client signaling recovery policy", () => {
hostOnline: true,
connectionId: "connection_12345678",
viewerPeerIds: [],
iceConfig: { iceServers: [] },
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
codeEntryPolicy: "open",
viewerAuthorizationGeneration: "viewer_generation_12345678",
mediaMode: "peer-assisted",
@@ -1483,7 +1483,7 @@ describe("client signaling recovery policy", () => {
hostOnline: true,
connectionId: null,
viewerPeerIds: [],
iceConfig: { iceServers: [] },
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
}),
])(
"terminates once when a server payload is incompatible with the current wire",
@@ -1710,6 +1710,7 @@ describe("WebRTC stats parsing", () => {
}),
entry("remote-candidate-a", "remote-candidate", {
candidateType: "srflx",
foundation: "sp1",
protocol: "udp",
address: "2001:db8::10",
port: 50_001,
@@ -1804,6 +1805,7 @@ describe("WebRTC stats parsing", () => {
mediaSourceFramesPerSecond: 58.5,
selectedCandidatePairId: "pair-a",
path: "direct",
natTraversalPath: "predicted",
iceProtocol: "udp",
localCandidateAddress: "192.0.2.10",
localCandidatePort: 50_000,
@@ -1840,6 +1842,12 @@ describe("WebRTC stats parsing", () => {
});
report.get("remote-candidate-a")!.address = "2001:db8::10";
report.get("remote-candidate-a")!.port = 50_001;
report.get("remote-candidate-a")!.foundation = "ordinary";
expect(
await collectConnectionMetrics(connection, "send", accumulator, {
trackIdentifier: "capture-track-a",
}),
).toMatchObject({ natTraversalPath: "ordinary" });
report.get("transport-a")!.selectedCandidatePairId = "pair-z";
const wrongTransportPair = await collectConnectionMetrics(
+17 -6
View File
@@ -726,7 +726,10 @@ describe("HostPeer source replacement", () => {
const signals: SignalPayload[] = [];
const peer = new HostPeer(
"viewer-peer",
{ iceServers: [{ urls: "stun:share.example.test:3478" }] },
{
iceServers: [{ urls: "stun:share.example.test:3478" }],
natPredictionStunUrls: ["stun:observer.example.test:3478"],
},
createStream(createTrack("video", "video"), null),
QUALITY_PROFILES["720p30"],
{
@@ -745,9 +748,15 @@ describe("HostPeer source replacement", () => {
{ urls: "stun:share.example.test:3478" },
{ urls: "stun:share.example.test:3479" },
{ urls: "stun:share.example.test:3480" },
{ urls: "stun:observer.example.test:3478" },
]);
const emitCandidate = (port: number | null): void => {
const surveyUrls = [
"stun:share.example.test:3478",
"stun:share.example.test:3479",
"stun:share.example.test:3480",
];
const emitCandidate = (port: number | null, url?: string): void => {
const event = new Event("icecandidate");
Object.defineProperty(event, "candidate", {
value:
@@ -760,13 +769,15 @@ describe("HostPeer source replacement", () => {
sdpMid: "0",
sdpMLineIndex: 0,
usernameFragment: "test",
url,
},
});
connection.dispatchEvent(event);
};
emitCandidate(40_000);
emitCandidate(40_003);
emitCandidate(40_006);
emitCandidate(50_000, "stun:observer.example.test:3478");
emitCandidate(40_000, surveyUrls[0]);
emitCandidate(40_003, surveyUrls[1]);
emitCandidate(40_006, surveyUrls[2]);
connection.iceGatheringState = "complete";
connection.dispatchEvent(new Event("icegatheringstatechange"));
emitCandidate(null);
@@ -788,7 +799,7 @@ describe("HostPeer source replacement", () => {
candidateSignals.filter((signal) =>
signal.candidate?.candidate.startsWith("candidate:base"),
),
).toHaveLength(3);
).toHaveLength(4);
expect(candidateSignals.at(-1)?.candidate).toBeNull();
expect(candidateSignals.filter((signal) => signal.candidate === null)).toHaveLength(1);
peer.dispose();
+36
View File
@@ -14,6 +14,7 @@ const SHARE_GENERATION = "share_generation_12345678";
function senderDiagnostics(state: "unknown" | "healthy" | "degraded") {
return {
natTraversalPath: "unknown",
reason:
state === "unknown" ? null : state === "healthy" ? "none" : "bandwidth",
framesPerSecond: null,
@@ -250,6 +251,41 @@ async function establishSfuRoom(
}
describe("HybridMediaRouter v9 runtime", () => {
it("logs only aggregate candidate origin for route signals", async () => {
const { router } = harness(2);
const debug = vi.spyOn(
router as unknown as {
debug(
roomId: string,
event: string,
details: Record<string, unknown>,
): void;
},
"debug",
);
try {
router.debugPeerSignal({
roomId: "1234",
sourcePeerId: "source_12345678",
targetPeerId: "target_12345678",
signalKind: "candidate",
candidateOrigin: "predicted",
authorization: "probe",
});
expect(debug).toHaveBeenCalledWith(
"1234",
"peer-signal",
expect.objectContaining({
candidateOrigin: "predicted",
signalKind: "candidate",
authorization: "probe",
}),
);
} finally {
await router.close();
}
});
it("projects exact endpoint copy context from the current snapshot", async () => {
const { router } = harness(2);
const internal = router as unknown as {
+5 -1
View File
@@ -10,6 +10,7 @@ describe("ordinary ICE configuration", () => {
"stun:stun-a.example.test:3478",
"stun:stun-b.example.test:3478",
],
natPredictionStunUrls: ["stun:observer.example.test:3478"],
}),
).toEqual({
iceServers: [
@@ -20,10 +21,13 @@ describe("ordinary ICE configuration", () => {
],
},
],
natPredictionStunUrls: ["stun:observer.example.test:3478"],
});
});
it("keeps local development explicit when STUN is absent", () => {
expect(createIceConfig({ stunUrls: [] })).toEqual({ iceServers: [] });
expect(
createIceConfig({ stunUrls: [], natPredictionStunUrls: [] }),
).toEqual({ iceServers: [], natPredictionStunUrls: [] });
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import {
candidateSignalOrigin,
isPredictedCandidateFoundation,
} from "../src/shared/nat-candidate.ts";
describe("NAT candidate diagnostics", () => {
it("recognizes only the bounded synthetic foundation namespace", () => {
expect(isPredictedCandidateFoundation("sp1")).toBe(true);
expect(isPredictedCandidateFoundation("sm4")).toBe(true);
expect(isPredictedCandidateFoundation("base")).toBe(false);
expect(isPredictedCandidateFoundation(null)).toBe(false);
});
it("classifies candidate signals without retaining endpoint data", () => {
expect(candidateSignalOrigin(null)).toBe("end");
expect(candidateSignalOrigin(" ")).toBe("end");
expect(
candidateSignalOrigin(
"candidate:sp2 1 udp 1 203.0.113.1 50000 typ srflx",
),
).toBe("predicted");
expect(
candidateSignalOrigin(
"candidate:base 1 udp 1 203.0.113.1 50000 typ srflx",
),
).toBe("ordinary");
});
});
+83 -1
View File
@@ -4,6 +4,8 @@ import {
iceServersWithNatPrediction,
MAX_NAT_PREDICTION_CANDIDATES,
NatPredictionCandidateBatch,
NatPredictionCandidateEmitter,
natPredictionSurveyUrls,
predictSrflxCandidates,
type SignalCandidate,
} from "../src/client/webrtc/nat-prediction.ts";
@@ -33,6 +35,12 @@ function portOf(value: SignalCandidate): number {
return Number(value?.candidate.trim().split(/\s+/)[5]);
}
function rtcCandidate(port: number): RTCIceCandidate {
return {
...candidate(port),
} as unknown as RTCIceCandidate;
}
describe("NAT prediction ICE adapter", () => {
it("leaves the configured servers unchanged when disabled", () => {
const servers = [
@@ -40,7 +48,42 @@ describe("NAT prediction ICE adapter", () => {
{ urls: "stun:other.example.test:5349" },
];
expect(iceServersWithNatPrediction(servers, false)).toEqual(servers);
expect(
iceServersWithNatPrediction(
servers,
false,
["stun:observer.example.test:3478"],
),
).toEqual(servers);
});
it("adds independent observers only inside the enabled experiment", () => {
const servers = [{ urls: "stun:share.example.test:3478" }];
expect(
iceServersWithNatPrediction(servers, true, [
"stun:observer.example.test:3478",
]),
).toEqual([
...servers,
{ urls: "stun:share.example.test:3479" },
{ urls: "stun:share.example.test:3480" },
{ urls: "stun:observer.example.test:3478" },
]);
expect(
natPredictionSurveyUrls(servers),
).toEqual(
new Set([
"stun:share.example.test:3478",
"stun:share.example.test:3479",
"stun:share.example.test:3480",
]),
);
expect(
natPredictionSurveyUrls([
{ urls: "stun:share.example.test:5349" },
]),
).toEqual(new Set());
});
it("derives only the two optional same-host survey listeners", () => {
@@ -158,4 +201,43 @@ describe("NAT prediction ICE adapter", () => {
expect(sent).toEqual([first]);
});
it("retains independent observations without using them as sequence evidence", () => {
const sent: Array<SignalCandidate | null> = [];
const batch = new NatPredictionCandidateBatch((value) => sent.push(value));
const external = candidate(50_000);
batch.add(external, false);
batch.add(candidate(40_000));
batch.add(candidate(40_003));
batch.add(candidate(40_006));
expect(sent[0]).toEqual(external);
expect(
sent.some((value) => value?.candidate.startsWith("candidate:sp")),
).toBe(true);
});
it("does not predict when the Browser omits candidate source URLs", () => {
const sent: Array<SignalCandidate | null> = [];
const emitter = new NatPredictionCandidateEmitter(
true,
(value) => sent.push(value),
new Set([
"stun:share.example.test:3478",
"stun:share.example.test:3479",
"stun:share.example.test:3480",
]),
);
emitter.add(rtcCandidate(40_000));
emitter.add(rtcCandidate(40_003));
emitter.add(rtcCandidate(40_006));
emitter.gatheringComplete();
expect(
sent.some((value) => value?.candidate.startsWith("candidate:sp")),
).toBe(false);
expect(sent.filter((value) => value !== null)).toHaveLength(3);
});
});
+33 -1
View File
@@ -56,6 +56,7 @@ const qualityEvidence = {
sequence: 0,
windowMs: 2_000,
metrics: {
natTraversalPath: "ordinary",
width: 1_920,
height: 1_080,
framesPerSecond: 59.8,
@@ -97,7 +98,7 @@ describe("client signaling protocol", () => {
"utf8",
);
expect(SIGNALING_PROTOCOL).toBe("screener-v18");
expect(SIGNALING_PROTOCOL).toBe("screener-v19");
expect(nativeWire).toMatch(/signalingProtocol\s*=\s*"screener-v6"/);
});
@@ -768,6 +769,14 @@ describe("client signaling protocol", () => {
it("accepts only strict, bounded viewer quality evidence", () => {
expect(clientMessageSchema.safeParse(qualityEvidence).success).toBe(true);
const { natTraversalPath: _path, ...legacyMetrics } =
qualityEvidence.metrics;
expect(
clientMessageSchema.safeParse({
...qualityEvidence,
metrics: legacyMetrics,
}).success,
).toBe(false);
expect(
clientMessageSchema.safeParse({
...qualityEvidence,
@@ -883,12 +892,21 @@ describe("client signaling protocol", () => {
routeRevision: 3,
state: "degraded",
diagnostics: {
natTraversalPath: "ordinary",
reason: "bandwidth",
framesPerSecond: 24,
bitrateKbps: 1_500,
},
} as const;
expect(clientMessageSchema.safeParse(sender).success).toBe(true);
const { natTraversalPath: _senderPath, ...legacyDiagnostics } =
sender.diagnostics;
expect(
clientMessageSchema.safeParse({
...sender,
diagnostics: legacyDiagnostics,
}).success,
).toBe(false);
expect(
clientMessageSchema.safeParse({
...sender,
@@ -911,6 +929,7 @@ describe("client signaling protocol", () => {
state: "healthy",
sampleTimestampMs: 2_000,
diagnostics: {
natTraversalPath: "ordinary",
reason: "none",
framesPerSecond: 30,
bitrateKbps: 2_000,
@@ -1253,6 +1272,7 @@ describe("server signaling protocol", () => {
viewerAuthorizationGeneration: "viewer_generation_12345678",
iceConfig: {
iceServers: [],
natPredictionStunUrls: [],
},
};
}
@@ -1275,6 +1295,18 @@ describe("server signaling protocol", () => {
expect(serverMessageSchema.safeParse(viewer).success).toBe(false);
});
it("requires the v19 NAT observation configuration", () => {
const message = authenticatedMessage(8);
const { natPredictionStunUrls: _urls, ...legacyIceConfig } =
message.iceConfig;
expect(
serverMessageSchema.safeParse({
...message,
iceConfig: legacyIceConfig,
}).success,
).toBe(false);
});
it("accepts dynamic viewer limits within the protocol boundary", () => {
expect(MAX_VIEWERS_PER_ROOM_LIMIT).toBe(20);
expect(serverMessageSchema.safeParse(authenticatedMessage(1)).success).toBe(
+1
View File
@@ -182,6 +182,7 @@ describe("native sender quality evidence", () => {
routeRevision: 7,
state: "healthy",
sampleTimestampMs: 2_000,
diagnostics: { natTraversalPath: "unknown" },
});
invalidateSenderQualityEvidence();
expect(senderQualityEvidenceFromSnapshot(nextSnapshot, 7)).toMatchObject({
+40
View File
@@ -18,6 +18,7 @@ describe("server configuration", () => {
expect(config.allowedOrigins).toEqual(new Set(["http://localhost:9123"]));
expect(config.roomDatabasePath).toBeUndefined();
expect(config.stunUrls).toEqual([]);
expect(config.natPredictionStunUrls).toEqual([]);
expect(config.maxViewersPerRoom).toBe(8);
expect(config.peerAssistedMedia).toBe(false);
expect(config.endpointMediaCopyCapacity).toBe(2);
@@ -326,6 +327,30 @@ describe("server configuration", () => {
).toEqual(["STUN:[2001:db8::1]:3478"]);
});
it("accepts a bounded independent NAT prediction STUN list", () => {
expect(
loadConfig({
NAT_PREDICTION_STUN_URLS:
"stun:observer-a.test:3478, stun:observer-b.test:3478",
}).natPredictionStunUrls,
).toEqual([
"stun:observer-a.test:3478",
"stun:observer-b.test:3478",
]);
});
it("rejects invalid or excessive NAT prediction STUN URLs", () => {
expect(() =>
loadConfig({ NAT_PREDICTION_STUN_URLS: "turn:observer.test:3478" }),
).toThrow("NAT_PREDICTION_STUN_URLS contains an invalid STUN URL");
expect(() =>
loadConfig({
NAT_PREDICTION_STUN_URLS:
"stun:a.test:3478,stun:b.test:3478,stun:c.test:3478",
}),
).toThrow("NAT_PREDICTION_STUN_URLS must contain at most 2 URLs");
});
it.each([
"stun:stun.test/path",
"stun:stun.test?transport=udp",
@@ -377,6 +402,21 @@ describe("server configuration", () => {
).toThrow("STUN_URLS must contain at most 8 URLs");
});
it("bounds ordinary and NAT prediction STUN URLs together", () => {
expect(() =>
loadConfig({
STUN_URLS: Array.from(
{ length: 7 },
(_, index) => `stun:ordinary-${index}.test:3478`,
).join(","),
NAT_PREDICTION_STUN_URLS:
"stun:observer-a.test:3478,stun:observer-b.test:3478",
}),
).toThrow(
"STUN_URLS and NAT_PREDICTION_STUN_URLS must contain at most 8 URLs together",
);
});
it("requires and accepts a bounded production site access password", () => {
expect(() =>
loadConfig({
+1
View File
@@ -57,6 +57,7 @@ function testConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
peerAssistedMedia: false,
endpointMediaCopyCapacity: 2,
stunUrls: [],
natPredictionStunUrls: [],
...overrides,
};
}
+24
View File
@@ -137,6 +137,7 @@ function testConfig(): ServerConfig {
peerAssistedMedia: false,
endpointMediaCopyCapacity: 2,
stunUrls: [],
natPredictionStunUrls: [],
};
}
@@ -151,6 +152,7 @@ async function startHarness(
peerAssistedMedia?: boolean;
endpointMediaCopyCapacity?: number;
stunUrls?: readonly string[];
natPredictionStunUrls?: readonly string[];
now?: () => number;
roomStore?: RoomStore;
room?: CreatedRoom;
@@ -162,6 +164,7 @@ async function startHarness(
config.endpointMediaCopyCapacity =
overrides.endpointMediaCopyCapacity ?? 2;
config.stunUrls = overrides.stunUrls ?? [];
config.natPredictionStunUrls = overrides.natPredictionStunUrls ?? [];
const maxViewersPerRoom = overrides.maxViewersPerRoom ?? 8;
config.maxViewersPerRoom = maxViewersPerRoom;
const roomStore =
@@ -420,6 +423,7 @@ function viewerQualityEvidenceMessage(
sequence,
windowMs: 2_000,
metrics: {
natTraversalPath: "ordinary",
width: 1_920,
height: 1_080,
framesPerSecond: 60,
@@ -542,6 +546,26 @@ describe("WebSocket signaling", () => {
expect(joined.routePolicy).toEqual(routePolicy);
});
it("delivers independent NAT experiment STUN without changing ordinary ICE", async () => {
const harness = await startHarness({
stunUrls: ["stun:ordinary.example.test:3478"],
natPredictionStunUrls: ["stun:observer.example.test:3478"],
});
const viewer = await openClient(harness.webSocketUrl);
const authenticated = await authenticate(
viewer,
harness.room,
"viewer",
"nat-observer-viewer",
null,
);
expect(authenticated.iceConfig).toEqual({
iceServers: [{ urls: ["stun:ordinary.example.test:3478"] }],
natPredictionStunUrls: ["stun:observer.example.test:3478"],
});
});
it("rebuilds a route when a Viewer reconnects before the Host after restart", async () => {
const directory = mkdtempSync(join(tmpdir(), "screener-route-restart-"));
const path = join(directory, "rooms.sqlite");
+15 -5
View File
@@ -342,7 +342,10 @@ describe("ViewerPeer connection generations", () => {
signals,
[],
[],
{ iceServers: [{ urls: "stun:share.example.test:3478" }] },
{
iceServers: [{ urls: "stun:share.example.test:3478" }],
natPredictionStunUrls: ["stun:observer.example.test:3478"],
},
true,
);
await peer.acceptSignal("host", offer("nat-room-policy"));
@@ -351,9 +354,15 @@ describe("ViewerPeer connection generations", () => {
{ urls: "stun:share.example.test:3478" },
{ urls: "stun:share.example.test:3479" },
{ urls: "stun:share.example.test:3480" },
{ urls: "stun:observer.example.test:3478" },
]);
const emitCandidate = (port: number): void => {
const surveyUrls = [
"stun:share.example.test:3478",
"stun:share.example.test:3479",
"stun:share.example.test:3480",
];
const emitCandidate = (port: number, url: string): void => {
const event = new Event("icecandidate");
Object.defineProperty(event, "candidate", {
value: {
@@ -363,13 +372,14 @@ describe("ViewerPeer connection generations", () => {
sdpMid: "0",
sdpMLineIndex: 0,
usernameFragment: "test",
url,
},
});
connection.dispatchEvent(event);
};
emitCandidate(40_000);
emitCandidate(40_003);
emitCandidate(40_006);
emitCandidate(40_000, surveyUrls[0]!);
emitCandidate(40_003, surveyUrls[1]!);
emitCandidate(40_006, surveyUrls[2]!);
connection.iceGatheringState = "complete";
connection.dispatchEvent(new Event("icegatheringstatechange"));
+3
View File
@@ -33,6 +33,7 @@ function receiveMetrics(
...EMPTY_METRICS,
sampleTimestampMs: 10_000,
sampleWindowMs: 2_000,
natTraversalPath: "predicted",
framesPerSecond: 60,
frameWidth: 1_920,
frameHeight: 1_080,
@@ -116,6 +117,7 @@ describe("viewer quality evidence", () => {
expect(qualityEvidenceWindowFromMetrics(receiveMetrics())).toEqual({
windowMs: 2_000,
metrics: {
natTraversalPath: "predicted",
width: 1_920,
height: 1_080,
framesPerSecond: 60,
@@ -175,6 +177,7 @@ describe("viewer quality evidence", () => {
}),
);
expect(bounded?.metrics).toMatchObject({
natTraversalPath: "predicted",
width: null,
height: null,
bitrateKbps: null,