@networkdiagnostics/sdk 0.1.0-pre.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/dist/index.cjs +415 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +127 -0
- package/dist/index.d.ts +127 -0
- package/dist/index.mjs +410 -0
- package/dist/index.mjs.map +1 -0
- package/dist/networktests.umd.js +2 -0
- package/dist/networktests.umd.js.map +1 -0
- package/dist/react/index.cjs +481 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +113 -0
- package/dist/react/index.d.ts +113 -0
- package/dist/react/index.mjs +478 -0
- package/dist/react/index.mjs.map +1 -0
- package/package.json +82 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UDP throughput probe session. Returned by the customer's backend from
|
|
3
|
+
* `POST /v1/probe/udp/sessions` on api.networktests.com.
|
|
4
|
+
*
|
|
5
|
+
* The frontend passes this to `nt.runUdpTest(session)`.
|
|
6
|
+
*/
|
|
7
|
+
interface UdpSession {
|
|
8
|
+
/** Opaque session ticket. Single-use; valid for ~30 minutes. */
|
|
9
|
+
sessionId: string;
|
|
10
|
+
/** WebSocket URL the SDK must connect to (region-pinned). */
|
|
11
|
+
wsUrl: string;
|
|
12
|
+
/** ICE servers to seed the RTCPeerConnection. Server may override via `welcome` message. */
|
|
13
|
+
iceServers?: RTCIceServer[];
|
|
14
|
+
/** Test duration in seconds (defaults to server config). */
|
|
15
|
+
duration?: number;
|
|
16
|
+
/** Packet rate (packets per second). */
|
|
17
|
+
rate?: number;
|
|
18
|
+
/** Packet size in bytes (max 1400). */
|
|
19
|
+
packetSize?: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* DNS leak probe session. Returned by `POST /v1/probe/dns-leak/sessions`.
|
|
23
|
+
*
|
|
24
|
+
* The SDK iterates `fqdns` and triggers DNS resolution of each one. Our
|
|
25
|
+
* authoritative DNS server records which recursive resolver did the
|
|
26
|
+
* lookup; the customer's backend later calls
|
|
27
|
+
* `GET /v1/probe/dns-leak/sessions/:id` to retrieve the resolver list.
|
|
28
|
+
*/
|
|
29
|
+
interface DnsLeakSession {
|
|
30
|
+
sessionId: string;
|
|
31
|
+
fqdns: string[];
|
|
32
|
+
}
|
|
33
|
+
interface LatencyStats {
|
|
34
|
+
/** Mean RTT in milliseconds. */
|
|
35
|
+
avg: number;
|
|
36
|
+
min: number;
|
|
37
|
+
max: number;
|
|
38
|
+
/** 50th percentile. */
|
|
39
|
+
p50: number;
|
|
40
|
+
/** 95th percentile. */
|
|
41
|
+
p95: number;
|
|
42
|
+
/** Down-sampled to 200 evenly-spaced points across the full test. */
|
|
43
|
+
samples?: number[];
|
|
44
|
+
}
|
|
45
|
+
interface UdpResult {
|
|
46
|
+
sessionId: string;
|
|
47
|
+
packetsSent: number;
|
|
48
|
+
packetsReceived: number;
|
|
49
|
+
packetsLost: number;
|
|
50
|
+
/** Packet loss percentage (0-100). */
|
|
51
|
+
lossPercent: number;
|
|
52
|
+
/** Inter-packet arrival jitter, in ms. */
|
|
53
|
+
jitter: number;
|
|
54
|
+
latency: LatencyStats;
|
|
55
|
+
/** Total test duration in ms (wall clock from WS open to result). */
|
|
56
|
+
durationMs: number;
|
|
57
|
+
completedAt: string;
|
|
58
|
+
}
|
|
59
|
+
interface UdpProgress {
|
|
60
|
+
packetsSent: number;
|
|
61
|
+
packetsReceived: number;
|
|
62
|
+
lossPercent: number;
|
|
63
|
+
elapsedMs: number;
|
|
64
|
+
}
|
|
65
|
+
interface RunUdpOptions {
|
|
66
|
+
/** Per-packet progress callback. Fires ~10x/sec, debounced. */
|
|
67
|
+
onProgress?: (progress: UdpProgress) => void;
|
|
68
|
+
/** AbortSignal — abort cancels the test cleanly (closes WS + DC). */
|
|
69
|
+
signal?: AbortSignal;
|
|
70
|
+
/** Hard timeout in ms. Defaults to (session.duration + 10) * 1000. */
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
}
|
|
73
|
+
interface RunDnsLeakOptions {
|
|
74
|
+
/** Called after each FQDN trigger resolves or errors. */
|
|
75
|
+
onResolved?: (fqdn: string) => void;
|
|
76
|
+
/** AbortSignal — cancels any in-flight image/fetch triggers. */
|
|
77
|
+
signal?: AbortSignal;
|
|
78
|
+
/** Hard timeout in ms. Default 15000. */
|
|
79
|
+
timeoutMs?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface UseUdpTestState {
|
|
83
|
+
result: UdpResult | null;
|
|
84
|
+
error: Error | null;
|
|
85
|
+
isRunning: boolean;
|
|
86
|
+
progress: UdpProgress | null;
|
|
87
|
+
}
|
|
88
|
+
interface UseUdpTestApi extends UseUdpTestState {
|
|
89
|
+
/** Start the test. Throws if already running. */
|
|
90
|
+
run: (session: UdpSession, options?: Omit<RunUdpOptions, "onProgress" | "signal">) => Promise<UdpResult>;
|
|
91
|
+
/** Abort an in-flight test. No-op if idle. */
|
|
92
|
+
cancel: () => void;
|
|
93
|
+
/** Reset state to initial. */
|
|
94
|
+
reset: () => void;
|
|
95
|
+
}
|
|
96
|
+
declare function useUdpTest(): UseUdpTestApi;
|
|
97
|
+
|
|
98
|
+
interface UseDnsLeakTestState {
|
|
99
|
+
isRunning: boolean;
|
|
100
|
+
error: Error | null;
|
|
101
|
+
/** FQDNs that have been triggered (server-side resolver list is separate). */
|
|
102
|
+
resolved: string[];
|
|
103
|
+
/** True once all FQDNs have been triggered. */
|
|
104
|
+
complete: boolean;
|
|
105
|
+
}
|
|
106
|
+
interface UseDnsLeakTestApi extends UseDnsLeakTestState {
|
|
107
|
+
run: (session: DnsLeakSession, options?: Omit<RunDnsLeakOptions, "onResolved" | "signal">) => Promise<void>;
|
|
108
|
+
cancel: () => void;
|
|
109
|
+
reset: () => void;
|
|
110
|
+
}
|
|
111
|
+
declare function useDnsLeakTest(): UseDnsLeakTestApi;
|
|
112
|
+
|
|
113
|
+
export { type UseDnsLeakTestApi, type UseDnsLeakTestState, type UseUdpTestApi, type UseUdpTestState, useDnsLeakTest, useUdpTest };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UDP throughput probe session. Returned by the customer's backend from
|
|
3
|
+
* `POST /v1/probe/udp/sessions` on api.networktests.com.
|
|
4
|
+
*
|
|
5
|
+
* The frontend passes this to `nt.runUdpTest(session)`.
|
|
6
|
+
*/
|
|
7
|
+
interface UdpSession {
|
|
8
|
+
/** Opaque session ticket. Single-use; valid for ~30 minutes. */
|
|
9
|
+
sessionId: string;
|
|
10
|
+
/** WebSocket URL the SDK must connect to (region-pinned). */
|
|
11
|
+
wsUrl: string;
|
|
12
|
+
/** ICE servers to seed the RTCPeerConnection. Server may override via `welcome` message. */
|
|
13
|
+
iceServers?: RTCIceServer[];
|
|
14
|
+
/** Test duration in seconds (defaults to server config). */
|
|
15
|
+
duration?: number;
|
|
16
|
+
/** Packet rate (packets per second). */
|
|
17
|
+
rate?: number;
|
|
18
|
+
/** Packet size in bytes (max 1400). */
|
|
19
|
+
packetSize?: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* DNS leak probe session. Returned by `POST /v1/probe/dns-leak/sessions`.
|
|
23
|
+
*
|
|
24
|
+
* The SDK iterates `fqdns` and triggers DNS resolution of each one. Our
|
|
25
|
+
* authoritative DNS server records which recursive resolver did the
|
|
26
|
+
* lookup; the customer's backend later calls
|
|
27
|
+
* `GET /v1/probe/dns-leak/sessions/:id` to retrieve the resolver list.
|
|
28
|
+
*/
|
|
29
|
+
interface DnsLeakSession {
|
|
30
|
+
sessionId: string;
|
|
31
|
+
fqdns: string[];
|
|
32
|
+
}
|
|
33
|
+
interface LatencyStats {
|
|
34
|
+
/** Mean RTT in milliseconds. */
|
|
35
|
+
avg: number;
|
|
36
|
+
min: number;
|
|
37
|
+
max: number;
|
|
38
|
+
/** 50th percentile. */
|
|
39
|
+
p50: number;
|
|
40
|
+
/** 95th percentile. */
|
|
41
|
+
p95: number;
|
|
42
|
+
/** Down-sampled to 200 evenly-spaced points across the full test. */
|
|
43
|
+
samples?: number[];
|
|
44
|
+
}
|
|
45
|
+
interface UdpResult {
|
|
46
|
+
sessionId: string;
|
|
47
|
+
packetsSent: number;
|
|
48
|
+
packetsReceived: number;
|
|
49
|
+
packetsLost: number;
|
|
50
|
+
/** Packet loss percentage (0-100). */
|
|
51
|
+
lossPercent: number;
|
|
52
|
+
/** Inter-packet arrival jitter, in ms. */
|
|
53
|
+
jitter: number;
|
|
54
|
+
latency: LatencyStats;
|
|
55
|
+
/** Total test duration in ms (wall clock from WS open to result). */
|
|
56
|
+
durationMs: number;
|
|
57
|
+
completedAt: string;
|
|
58
|
+
}
|
|
59
|
+
interface UdpProgress {
|
|
60
|
+
packetsSent: number;
|
|
61
|
+
packetsReceived: number;
|
|
62
|
+
lossPercent: number;
|
|
63
|
+
elapsedMs: number;
|
|
64
|
+
}
|
|
65
|
+
interface RunUdpOptions {
|
|
66
|
+
/** Per-packet progress callback. Fires ~10x/sec, debounced. */
|
|
67
|
+
onProgress?: (progress: UdpProgress) => void;
|
|
68
|
+
/** AbortSignal — abort cancels the test cleanly (closes WS + DC). */
|
|
69
|
+
signal?: AbortSignal;
|
|
70
|
+
/** Hard timeout in ms. Defaults to (session.duration + 10) * 1000. */
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
}
|
|
73
|
+
interface RunDnsLeakOptions {
|
|
74
|
+
/** Called after each FQDN trigger resolves or errors. */
|
|
75
|
+
onResolved?: (fqdn: string) => void;
|
|
76
|
+
/** AbortSignal — cancels any in-flight image/fetch triggers. */
|
|
77
|
+
signal?: AbortSignal;
|
|
78
|
+
/** Hard timeout in ms. Default 15000. */
|
|
79
|
+
timeoutMs?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface UseUdpTestState {
|
|
83
|
+
result: UdpResult | null;
|
|
84
|
+
error: Error | null;
|
|
85
|
+
isRunning: boolean;
|
|
86
|
+
progress: UdpProgress | null;
|
|
87
|
+
}
|
|
88
|
+
interface UseUdpTestApi extends UseUdpTestState {
|
|
89
|
+
/** Start the test. Throws if already running. */
|
|
90
|
+
run: (session: UdpSession, options?: Omit<RunUdpOptions, "onProgress" | "signal">) => Promise<UdpResult>;
|
|
91
|
+
/** Abort an in-flight test. No-op if idle. */
|
|
92
|
+
cancel: () => void;
|
|
93
|
+
/** Reset state to initial. */
|
|
94
|
+
reset: () => void;
|
|
95
|
+
}
|
|
96
|
+
declare function useUdpTest(): UseUdpTestApi;
|
|
97
|
+
|
|
98
|
+
interface UseDnsLeakTestState {
|
|
99
|
+
isRunning: boolean;
|
|
100
|
+
error: Error | null;
|
|
101
|
+
/** FQDNs that have been triggered (server-side resolver list is separate). */
|
|
102
|
+
resolved: string[];
|
|
103
|
+
/** True once all FQDNs have been triggered. */
|
|
104
|
+
complete: boolean;
|
|
105
|
+
}
|
|
106
|
+
interface UseDnsLeakTestApi extends UseDnsLeakTestState {
|
|
107
|
+
run: (session: DnsLeakSession, options?: Omit<RunDnsLeakOptions, "onResolved" | "signal">) => Promise<void>;
|
|
108
|
+
cancel: () => void;
|
|
109
|
+
reset: () => void;
|
|
110
|
+
}
|
|
111
|
+
declare function useDnsLeakTest(): UseDnsLeakTestApi;
|
|
112
|
+
|
|
113
|
+
export { type UseDnsLeakTestApi, type UseDnsLeakTestState, type UseUdpTestApi, type UseUdpTestState, useDnsLeakTest, useUdpTest };
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { useState, useRef, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
// src/react/useUdpTest.ts
|
|
4
|
+
|
|
5
|
+
// src/errors.ts
|
|
6
|
+
var NetworkTestsError = class extends Error {
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "NetworkTestsError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
var AbortError = class extends NetworkTestsError {
|
|
14
|
+
constructor(message = "Operation aborted") {
|
|
15
|
+
super("ABORTED", message);
|
|
16
|
+
this.name = "AbortError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var TimeoutError = class extends NetworkTestsError {
|
|
20
|
+
constructor(message = "Operation timed out") {
|
|
21
|
+
super("TIMEOUT", message);
|
|
22
|
+
this.name = "TimeoutError";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// src/udp/webrtc.ts
|
|
27
|
+
function createPeerConnection(iceServers) {
|
|
28
|
+
return new RTCPeerConnection({ iceServers });
|
|
29
|
+
}
|
|
30
|
+
function setupDataChannel(pc) {
|
|
31
|
+
const channel = pc.createDataChannel("test", { ordered: false });
|
|
32
|
+
const ready = new Promise((resolve, reject) => {
|
|
33
|
+
const onOpen = () => {
|
|
34
|
+
cleanup();
|
|
35
|
+
resolve();
|
|
36
|
+
};
|
|
37
|
+
const onError = () => {
|
|
38
|
+
cleanup();
|
|
39
|
+
reject(new NetworkTestsError("DC_ERROR", "DataChannel error before open"));
|
|
40
|
+
};
|
|
41
|
+
const onClose = () => {
|
|
42
|
+
cleanup();
|
|
43
|
+
reject(new NetworkTestsError("DC_CLOSED", "DataChannel closed before open"));
|
|
44
|
+
};
|
|
45
|
+
function cleanup() {
|
|
46
|
+
channel.removeEventListener("open", onOpen);
|
|
47
|
+
channel.removeEventListener("error", onError);
|
|
48
|
+
channel.removeEventListener("close", onClose);
|
|
49
|
+
}
|
|
50
|
+
channel.addEventListener("open", onOpen);
|
|
51
|
+
channel.addEventListener("error", onError);
|
|
52
|
+
channel.addEventListener("close", onClose);
|
|
53
|
+
});
|
|
54
|
+
return { channel, ready };
|
|
55
|
+
}
|
|
56
|
+
async function createOffer(pc) {
|
|
57
|
+
const offer = await pc.createOffer();
|
|
58
|
+
await pc.setLocalDescription(offer);
|
|
59
|
+
return offer;
|
|
60
|
+
}
|
|
61
|
+
async function applyAnswer(pc, sdp) {
|
|
62
|
+
await pc.setRemoteDescription({ type: "answer", sdp });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/udp/packetLoop.ts
|
|
66
|
+
function startPacketLoop(opts) {
|
|
67
|
+
const { channel, onProgress } = opts;
|
|
68
|
+
const progressMs = opts.progressIntervalMs ?? 100;
|
|
69
|
+
const startTs = Date.now();
|
|
70
|
+
let received = 0;
|
|
71
|
+
let highestSeq = -1;
|
|
72
|
+
let stopped = false;
|
|
73
|
+
const onMsg = (ev) => {
|
|
74
|
+
if (stopped) return;
|
|
75
|
+
try {
|
|
76
|
+
const msg = JSON.parse(ev.data);
|
|
77
|
+
if (msg && msg.type === "packet" && typeof msg.seq === "number") {
|
|
78
|
+
received++;
|
|
79
|
+
if (msg.seq > highestSeq) highestSeq = msg.seq;
|
|
80
|
+
try {
|
|
81
|
+
channel.send(JSON.stringify({ type: "ack", seq: msg.seq }));
|
|
82
|
+
} catch {
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
channel.addEventListener("message", onMsg);
|
|
89
|
+
let progressTimer = null;
|
|
90
|
+
if (onProgress) {
|
|
91
|
+
progressTimer = setInterval(() => {
|
|
92
|
+
if (stopped) return;
|
|
93
|
+
const sent = highestSeq + 1;
|
|
94
|
+
const loss = sent > 0 ? (sent - received) / sent * 100 : 0;
|
|
95
|
+
onProgress({
|
|
96
|
+
packetsSent: sent,
|
|
97
|
+
packetsReceived: received,
|
|
98
|
+
lossPercent: Math.round(loss * 100) / 100,
|
|
99
|
+
elapsedMs: Date.now() - startTs
|
|
100
|
+
});
|
|
101
|
+
}, progressMs);
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
stop() {
|
|
105
|
+
if (stopped) return;
|
|
106
|
+
stopped = true;
|
|
107
|
+
channel.removeEventListener("message", onMsg);
|
|
108
|
+
if (progressTimer) clearInterval(progressTimer);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/udp/signaling.ts
|
|
114
|
+
function isServerMsg(value) {
|
|
115
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/udp/index.ts
|
|
119
|
+
var DEFAULT_ICE_SERVERS = [
|
|
120
|
+
{ urls: "stun:stun.l.google.com:19302" }
|
|
121
|
+
];
|
|
122
|
+
async function runUdpTest(session, options = {}) {
|
|
123
|
+
const startedAt = Date.now();
|
|
124
|
+
const { signal, onProgress } = options;
|
|
125
|
+
const timeoutMs = options.timeoutMs ?? ((session.duration ?? 30) + 10) * 1e3;
|
|
126
|
+
if (signal?.aborted) throw new AbortError();
|
|
127
|
+
const ws = new WebSocket(session.wsUrl);
|
|
128
|
+
ws.binaryType = "arraybuffer";
|
|
129
|
+
let pc = null;
|
|
130
|
+
let dataChannel = null;
|
|
131
|
+
let packetLoop = null;
|
|
132
|
+
let timeoutHandle = null;
|
|
133
|
+
let abortHandler = null;
|
|
134
|
+
const cleanup = () => {
|
|
135
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
136
|
+
if (abortHandler && signal) signal.removeEventListener("abort", abortHandler);
|
|
137
|
+
packetLoop?.stop();
|
|
138
|
+
try {
|
|
139
|
+
dataChannel?.close();
|
|
140
|
+
} catch {
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
pc?.close();
|
|
144
|
+
} catch {
|
|
145
|
+
}
|
|
146
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
147
|
+
try {
|
|
148
|
+
ws.close();
|
|
149
|
+
} catch {
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const fail = (err) => {
|
|
155
|
+
cleanup();
|
|
156
|
+
reject(err);
|
|
157
|
+
};
|
|
158
|
+
const succeed = (result) => {
|
|
159
|
+
cleanup();
|
|
160
|
+
resolve(result);
|
|
161
|
+
};
|
|
162
|
+
timeoutHandle = setTimeout(
|
|
163
|
+
() => fail(new TimeoutError(`UDP test exceeded ${timeoutMs}ms`)),
|
|
164
|
+
timeoutMs
|
|
165
|
+
);
|
|
166
|
+
if (signal) {
|
|
167
|
+
abortHandler = () => fail(new AbortError());
|
|
168
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
169
|
+
}
|
|
170
|
+
const send = (msg) => {
|
|
171
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
|
172
|
+
};
|
|
173
|
+
ws.addEventListener(
|
|
174
|
+
"error",
|
|
175
|
+
() => fail(new NetworkTestsError("WS_ERROR", "WebSocket error"))
|
|
176
|
+
);
|
|
177
|
+
ws.addEventListener("close", (ev) => {
|
|
178
|
+
if (ws.readyState === WebSocket.CLOSED) {
|
|
179
|
+
const reason = ev.reason || `WebSocket closed (code ${ev.code})`;
|
|
180
|
+
fail(new NetworkTestsError("WS_CLOSED", reason));
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
ws.addEventListener("open", async () => {
|
|
184
|
+
});
|
|
185
|
+
ws.addEventListener("message", async (ev) => {
|
|
186
|
+
let msg;
|
|
187
|
+
try {
|
|
188
|
+
msg = JSON.parse(String(ev.data));
|
|
189
|
+
} catch {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!isServerMsg(msg)) return;
|
|
193
|
+
const m = msg;
|
|
194
|
+
try {
|
|
195
|
+
switch (m.type) {
|
|
196
|
+
case "welcome": {
|
|
197
|
+
const iceServers = [
|
|
198
|
+
...session.iceServers ?? DEFAULT_ICE_SERVERS
|
|
199
|
+
];
|
|
200
|
+
if (m.turn) {
|
|
201
|
+
iceServers.push({
|
|
202
|
+
urls: m.turn.urls,
|
|
203
|
+
username: m.turn.username,
|
|
204
|
+
credential: m.turn.credential
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
pc = createPeerConnection(iceServers);
|
|
208
|
+
pc.addEventListener("icecandidate", (e) => {
|
|
209
|
+
if (e.candidate) {
|
|
210
|
+
send({ type: "ice-candidate", candidate: e.candidate.toJSON() });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
const dcSetup = setupDataChannel(pc);
|
|
214
|
+
dataChannel = dcSetup.channel;
|
|
215
|
+
const offer = await createOffer(pc);
|
|
216
|
+
send({ type: "offer", sdp: offer.sdp ?? "" });
|
|
217
|
+
dcSetup.ready.then(() => send({ type: "datachannel-ready" })).catch((err) => fail(toError(err)));
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case "answer": {
|
|
221
|
+
if (!pc) throw new NetworkTestsError("BAD_STATE", "answer before welcome");
|
|
222
|
+
await applyAnswer(pc, m.sdp.sdp);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case "ice-candidate": {
|
|
226
|
+
if (!pc) return;
|
|
227
|
+
try {
|
|
228
|
+
await pc.addIceCandidate(m.candidate);
|
|
229
|
+
} catch {
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case "datachannel-open": {
|
|
234
|
+
if (!dataChannel) throw new NetworkTestsError("BAD_STATE", "no DC");
|
|
235
|
+
packetLoop = startPacketLoop({ channel: dataChannel, onProgress });
|
|
236
|
+
send({
|
|
237
|
+
type: "start-packet-test",
|
|
238
|
+
duration: session.duration,
|
|
239
|
+
rate: session.rate,
|
|
240
|
+
packetSize: session.packetSize
|
|
241
|
+
});
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
case "test-started":
|
|
245
|
+
break;
|
|
246
|
+
case "test-complete": {
|
|
247
|
+
const r = m.results;
|
|
248
|
+
succeed({
|
|
249
|
+
sessionId: session.sessionId,
|
|
250
|
+
packetsSent: r.packetsSent,
|
|
251
|
+
packetsReceived: r.packetsReceived,
|
|
252
|
+
packetsLost: r.packetsLost,
|
|
253
|
+
lossPercent: r.lossPercent,
|
|
254
|
+
jitter: r.jitter,
|
|
255
|
+
latency: {
|
|
256
|
+
avg: r.latency.avg,
|
|
257
|
+
min: r.latency.min,
|
|
258
|
+
max: r.latency.max,
|
|
259
|
+
p50: r.latency.p50,
|
|
260
|
+
p95: r.latency.p95,
|
|
261
|
+
samples: r.latency.samples
|
|
262
|
+
},
|
|
263
|
+
durationMs: Date.now() - startedAt,
|
|
264
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
265
|
+
});
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
case "test-stopped":
|
|
269
|
+
fail(new NetworkTestsError("TEST_STOPPED", "Server stopped the test"));
|
|
270
|
+
break;
|
|
271
|
+
case "error":
|
|
272
|
+
fail(new NetworkTestsError("SERVER_ERROR", m.message));
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
} catch (err) {
|
|
276
|
+
fail(toError(err));
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
function toError(value) {
|
|
282
|
+
if (value instanceof Error) return value;
|
|
283
|
+
return new NetworkTestsError("UNKNOWN", String(value));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/react/useUdpTest.ts
|
|
287
|
+
var INITIAL = {
|
|
288
|
+
result: null,
|
|
289
|
+
error: null,
|
|
290
|
+
isRunning: false,
|
|
291
|
+
progress: null
|
|
292
|
+
};
|
|
293
|
+
function useUdpTest() {
|
|
294
|
+
const [state, setState] = useState(INITIAL);
|
|
295
|
+
const abortRef = useRef(null);
|
|
296
|
+
const reset = useCallback(() => {
|
|
297
|
+
abortRef.current?.abort();
|
|
298
|
+
abortRef.current = null;
|
|
299
|
+
setState(INITIAL);
|
|
300
|
+
}, []);
|
|
301
|
+
const cancel = useCallback(() => {
|
|
302
|
+
abortRef.current?.abort();
|
|
303
|
+
}, []);
|
|
304
|
+
const run = useCallback(async (session, options) => {
|
|
305
|
+
abortRef.current?.abort();
|
|
306
|
+
const ctrl = new AbortController();
|
|
307
|
+
abortRef.current = ctrl;
|
|
308
|
+
setState({ ...INITIAL, isRunning: true });
|
|
309
|
+
try {
|
|
310
|
+
const result = await runUdpTest(session, {
|
|
311
|
+
...options,
|
|
312
|
+
signal: ctrl.signal,
|
|
313
|
+
onProgress: (progress) => {
|
|
314
|
+
setState((s) => s.isRunning ? { ...s, progress } : s);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
setState({ result, error: null, isRunning: false, progress: null });
|
|
318
|
+
return result;
|
|
319
|
+
} catch (err) {
|
|
320
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
321
|
+
setState({ result: null, error, isRunning: false, progress: null });
|
|
322
|
+
throw error;
|
|
323
|
+
} finally {
|
|
324
|
+
if (abortRef.current === ctrl) abortRef.current = null;
|
|
325
|
+
}
|
|
326
|
+
}, []);
|
|
327
|
+
return { ...state, run, cancel, reset };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/dnsleak/trigger.ts
|
|
331
|
+
var ABORT_AFTER_MS = 5e3;
|
|
332
|
+
function triggerDnsResolution(fqdn, signal) {
|
|
333
|
+
if (signal?.aborted) return Promise.resolve();
|
|
334
|
+
if (typeof Image !== "undefined") {
|
|
335
|
+
return triggerViaImage(fqdn, signal);
|
|
336
|
+
}
|
|
337
|
+
return triggerViaFetch(fqdn, signal);
|
|
338
|
+
}
|
|
339
|
+
function triggerViaImage(fqdn, signal) {
|
|
340
|
+
return new Promise((resolve) => {
|
|
341
|
+
const img = new Image();
|
|
342
|
+
let settled = false;
|
|
343
|
+
const finish = () => {
|
|
344
|
+
if (settled) return;
|
|
345
|
+
settled = true;
|
|
346
|
+
img.src = "";
|
|
347
|
+
resolve();
|
|
348
|
+
};
|
|
349
|
+
const onAbort = () => finish();
|
|
350
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
351
|
+
const timer = setTimeout(finish, ABORT_AFTER_MS);
|
|
352
|
+
img.onload = () => {
|
|
353
|
+
clearTimeout(timer);
|
|
354
|
+
finish();
|
|
355
|
+
};
|
|
356
|
+
img.onerror = () => {
|
|
357
|
+
clearTimeout(timer);
|
|
358
|
+
finish();
|
|
359
|
+
};
|
|
360
|
+
img.src = `https://${fqdn}/_/leak.gif?t=${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
function triggerViaFetch(fqdn, signal) {
|
|
364
|
+
const controller = new AbortController();
|
|
365
|
+
const abortSignal = signal ? mergeSignals(signal, controller.signal) : controller.signal;
|
|
366
|
+
const timer = setTimeout(() => controller.abort(), ABORT_AFTER_MS);
|
|
367
|
+
return fetch(`https://${fqdn}/_/leak?t=${Date.now()}`, {
|
|
368
|
+
method: "GET",
|
|
369
|
+
mode: "no-cors",
|
|
370
|
+
cache: "no-store",
|
|
371
|
+
signal: abortSignal
|
|
372
|
+
}).catch(() => void 0).finally(() => clearTimeout(timer)).then(() => void 0);
|
|
373
|
+
}
|
|
374
|
+
function mergeSignals(a, b) {
|
|
375
|
+
const c = new AbortController();
|
|
376
|
+
const onAbort = () => c.abort();
|
|
377
|
+
if (a.aborted || b.aborted) c.abort();
|
|
378
|
+
else {
|
|
379
|
+
a.addEventListener("abort", onAbort, { once: true });
|
|
380
|
+
b.addEventListener("abort", onAbort, { once: true });
|
|
381
|
+
}
|
|
382
|
+
return c.signal;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// src/dnsleak/index.ts
|
|
386
|
+
async function runDnsLeakTest(session, options = {}) {
|
|
387
|
+
if (options.signal?.aborted) throw new AbortError();
|
|
388
|
+
const timeoutMs = options.timeoutMs ?? 15e3;
|
|
389
|
+
let timeoutHandle = null;
|
|
390
|
+
const timeoutController = new AbortController();
|
|
391
|
+
const combinedSignal = mergeSignals2(options.signal, timeoutController.signal);
|
|
392
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
393
|
+
timeoutHandle = setTimeout(() => {
|
|
394
|
+
timeoutController.abort();
|
|
395
|
+
reject(new TimeoutError(`DNS-leak test exceeded ${timeoutMs}ms`));
|
|
396
|
+
}, timeoutMs);
|
|
397
|
+
});
|
|
398
|
+
const abortPromise = new Promise((_, reject) => {
|
|
399
|
+
options.signal?.addEventListener(
|
|
400
|
+
"abort",
|
|
401
|
+
() => reject(new AbortError()),
|
|
402
|
+
{ once: true }
|
|
403
|
+
);
|
|
404
|
+
});
|
|
405
|
+
const triggers = session.fqdns.map(
|
|
406
|
+
(fqdn) => triggerDnsResolution(fqdn, combinedSignal).then(() => {
|
|
407
|
+
options.onResolved?.(fqdn);
|
|
408
|
+
})
|
|
409
|
+
);
|
|
410
|
+
try {
|
|
411
|
+
await Promise.race([
|
|
412
|
+
Promise.all(triggers),
|
|
413
|
+
timeoutPromise,
|
|
414
|
+
abortPromise
|
|
415
|
+
]);
|
|
416
|
+
} finally {
|
|
417
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function mergeSignals2(a, b) {
|
|
421
|
+
if (!a) return b;
|
|
422
|
+
const c = new AbortController();
|
|
423
|
+
if (a.aborted || b.aborted) c.abort();
|
|
424
|
+
else {
|
|
425
|
+
a.addEventListener("abort", () => c.abort(), { once: true });
|
|
426
|
+
b.addEventListener("abort", () => c.abort(), { once: true });
|
|
427
|
+
}
|
|
428
|
+
return c.signal;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// src/react/useDnsLeakTest.ts
|
|
432
|
+
var INITIAL2 = {
|
|
433
|
+
isRunning: false,
|
|
434
|
+
error: null,
|
|
435
|
+
resolved: [],
|
|
436
|
+
complete: false
|
|
437
|
+
};
|
|
438
|
+
function useDnsLeakTest() {
|
|
439
|
+
const [state, setState] = useState(INITIAL2);
|
|
440
|
+
const abortRef = useRef(null);
|
|
441
|
+
const reset = useCallback(() => {
|
|
442
|
+
abortRef.current?.abort();
|
|
443
|
+
abortRef.current = null;
|
|
444
|
+
setState(INITIAL2);
|
|
445
|
+
}, []);
|
|
446
|
+
const cancel = useCallback(() => {
|
|
447
|
+
abortRef.current?.abort();
|
|
448
|
+
}, []);
|
|
449
|
+
const run = useCallback(async (session, options) => {
|
|
450
|
+
abortRef.current?.abort();
|
|
451
|
+
const ctrl = new AbortController();
|
|
452
|
+
abortRef.current = ctrl;
|
|
453
|
+
setState({ ...INITIAL2, isRunning: true });
|
|
454
|
+
try {
|
|
455
|
+
await runDnsLeakTest(session, {
|
|
456
|
+
...options,
|
|
457
|
+
signal: ctrl.signal,
|
|
458
|
+
onResolved: (fqdn) => {
|
|
459
|
+
setState(
|
|
460
|
+
(s) => s.isRunning ? { ...s, resolved: [...s.resolved, fqdn] } : s
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
setState((s) => ({ ...s, isRunning: false, complete: true }));
|
|
465
|
+
} catch (err) {
|
|
466
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
467
|
+
setState((s) => ({ ...s, isRunning: false, error }));
|
|
468
|
+
throw error;
|
|
469
|
+
} finally {
|
|
470
|
+
if (abortRef.current === ctrl) abortRef.current = null;
|
|
471
|
+
}
|
|
472
|
+
}, []);
|
|
473
|
+
return { ...state, run, cancel, reset };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export { useDnsLeakTest, useUdpTest };
|
|
477
|
+
//# sourceMappingURL=index.mjs.map
|
|
478
|
+
//# sourceMappingURL=index.mjs.map
|