@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 ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@networkdiagnostics/sdk` are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
6
+ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+ - Initial scaffolding: package skeleton, TypeScript build via tsup, vitest test runner.
12
+ - `NetworkTests` class with `runUdpTest()` and `runDnsLeakTest()` methods.
13
+ - UDP throughput probe: WebSocket signaling, RTCPeerConnection + DataChannel orchestration,
14
+ client-side ack loop, server-authoritative result delivery via `test-complete` message.
15
+ - DNS leak probe: parallel FQDN trigger via `Image()` (primary) and `fetch(no-cors)` (fallback).
16
+ - React adapters: `useUdpTest`, `useDnsLeakTest` hooks under `@networkdiagnostics/sdk/react`.
17
+ - Error hierarchy: `NetworkTestsError`, `AbortError`, `TimeoutError`.
18
+ - Unit tests for signaling type guard, error classes, and DNS trigger.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 networktests.com
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # @networkdiagnostics/sdk
2
+
3
+ Browser SDK for the [networktests.com API](https://api.networktests.com/v1/docs). Run UDP throughput tests and DNS-leak probes from your frontend without reimplementing WebRTC signaling or DNS query orchestration.
4
+
5
+ ```
6
+ npm install @networkdiagnostics/sdk
7
+ ```
8
+
9
+ ## Why an SDK?
10
+
11
+ Two of the endpoints on `api.networktests.com/v1/*` need cooperation from the end-user's browser:
12
+
13
+ - **UDP throughput** requires a WebRTC DataChannel — the browser pushes thousands of packets through it so we can measure loss, latency, and jitter from the path your customer's network actually uses.
14
+ - **DNS leak** requires the browser to resolve 10 short-lived FQDNs so our authoritative DNS server can record which recursive resolver each query hit.
15
+
16
+ The SDK hides this. Your application code stays a few lines.
17
+
18
+ ## Security model
19
+
20
+ **The SDK never sees your API key.**
21
+
22
+ ```
23
+ ┌──────────────────────────────┐ ┌────────────────────────────┐
24
+ │ Your backend │ HTTPS │ api.networktests.com │
25
+ │ (holds the API key) │ ────▶ │ POST /v1/probe/udp/ │
26
+ │ │ ◀──── │ sessions │
27
+ │ POST /v1/probe/udp/ │ │ → { sessionId, wsUrl } │
28
+ │ sessions │ └────────────────────────────┘
29
+ └──────┬───────────────────────┘
30
+ │ session (opaque ticket)
31
+
32
+ ┌──────────────────────────────┐ ┌────────────────────────────┐
33
+ │ Your frontend │ WSS │ api.networktests.com │
34
+ │ (this SDK) │ ────▶ │ /ws/v1/probe/udp/<id> │
35
+ │ nt.runUdpTest(session) │ └────────────────────────────┘
36
+ └──────────────────────────────┘
37
+ ```
38
+
39
+ Your backend mints a single-use session ticket via the authenticated API and ships it to the browser. The SDK uses the ticket to talk to our signaling server. Same shape as Stripe Elements, Twilio, or 100ms — frontend SDKs never hold long-lived secrets.
40
+
41
+ ## Quickstart — UDP throughput
42
+
43
+ **Backend** (any language, here in Node):
44
+
45
+ ```js
46
+ const r = await fetch("https://api.networktests.com/v1/probe/udp/sessions", {
47
+ method: "POST",
48
+ headers: {
49
+ "Authorization": "Bearer ntsk_live_…",
50
+ "Content-Type": "application/json",
51
+ },
52
+ body: JSON.stringify({ duration: 10, rate: 200, packetSize: 256 }),
53
+ });
54
+ const { data } = await r.json();
55
+ // → { sessionId, wsUrl, iceServers, duration, rate, packetSize }
56
+ res.json(data);
57
+ ```
58
+
59
+ **Frontend**:
60
+
61
+ ```ts
62
+ import { NetworkTests } from "@networkdiagnostics/sdk";
63
+
64
+ const nt = new NetworkTests();
65
+ const session = await fetch("/my-backend/start-udp").then(r => r.json());
66
+
67
+ const result = await nt.runUdpTest(session, {
68
+ onProgress: ({ packetsSent, packetsReceived, lossPercent }) => {
69
+ console.log(`${packetsReceived}/${packetsSent} (${lossPercent}%)`);
70
+ },
71
+ });
72
+
73
+ console.log(`loss: ${result.lossPercent}%`);
74
+ console.log(`p95 latency: ${result.latency.p95} ms`);
75
+ console.log(`jitter: ${result.jitter} ms`);
76
+ ```
77
+
78
+ ## Quickstart — DNS leak
79
+
80
+ **Backend**:
81
+
82
+ ```js
83
+ const r = await fetch("https://api.networktests.com/v1/probe/dns-leak/sessions", {
84
+ method: "POST",
85
+ headers: { "Authorization": "Bearer ntsk_live_…" },
86
+ });
87
+ const { data } = await r.json();
88
+ // → { sessionId, fqdns: [10 short-lived names] }
89
+ res.json(data);
90
+ ```
91
+
92
+ **Frontend**:
93
+
94
+ ```ts
95
+ import { NetworkTests } from "@networkdiagnostics/sdk";
96
+
97
+ const nt = new NetworkTests();
98
+ const session = await fetch("/my-backend/start-leak-test").then(r => r.json());
99
+ await nt.runDnsLeakTest(session);
100
+
101
+ // Poll your backend for the resolver list.
102
+ const leak = await fetch(`/my-backend/leak-results/${session.sessionId}`)
103
+ .then(r => r.json());
104
+ // → { resolvers: [{ ip, org, asn, country, encrypted, known_provider }, ...] }
105
+ ```
106
+
107
+ ## React
108
+
109
+ ```tsx
110
+ import { useUdpTest } from "@networkdiagnostics/sdk/react";
111
+
112
+ function SpeedTest({ session }) {
113
+ const { run, isRunning, progress, result, error } = useUdpTest();
114
+
115
+ if (error) return <div>Error: {error.message}</div>;
116
+ if (result) return <div>Loss: {result.lossPercent}%</div>;
117
+ if (isRunning) return <div>{progress?.packetsReceived ?? 0} packets…</div>;
118
+ return <button onClick={() => run(session)}>Start</button>;
119
+ }
120
+ ```
121
+
122
+ ## CDN drop-in (no build step)
123
+
124
+ ```html
125
+ <script src="https://cdn.jsdelivr.net/npm/@networkdiagnostics/sdk@0/dist/networktests.umd.js"></script>
126
+ <script>
127
+ const nt = new NetworkTests.NetworkTests();
128
+ // ...
129
+ </script>
130
+ ```
131
+
132
+ ## API reference
133
+
134
+ ### `new NetworkTests()`
135
+
136
+ Takes no arguments. The SDK is keyless on the wire.
137
+
138
+ ### `nt.runUdpTest(session, options?)`
139
+
140
+ | Param | Type | Notes |
141
+ |---|---|---|
142
+ | `session` | `UdpSession` | From your backend's call to `POST /v1/probe/udp/sessions`. |
143
+ | `options.onProgress` | `(p: UdpProgress) => void` | Fired ~10× per second during the test. |
144
+ | `options.signal` | `AbortSignal` | Cancels the test cleanly. |
145
+ | `options.timeoutMs` | `number` | Hard timeout. Defaults to `(session.duration + 10) * 1000`. |
146
+
147
+ Resolves with `UdpResult`. Rejects with `NetworkTestsError`, `AbortError`, or `TimeoutError`.
148
+
149
+ ### `nt.runDnsLeakTest(session, options?)`
150
+
151
+ | Param | Type | Notes |
152
+ |---|---|---|
153
+ | `session` | `DnsLeakSession` | From `POST /v1/probe/dns-leak/sessions`. |
154
+ | `options.onResolved` | `(fqdn: string) => void` | Fired per FQDN. |
155
+ | `options.signal` | `AbortSignal` | Cancels in-flight triggers. |
156
+ | `options.timeoutMs` | `number` | Default 15000. |
157
+
158
+ Resolves with `void` once all triggers complete. The resolver list lives server-side — poll the `GET /v1/probe/dns-leak/sessions/:id` endpoint from your backend.
159
+
160
+ ### Errors
161
+
162
+ ```ts
163
+ import {
164
+ NetworkTestsError, // base class with .code
165
+ AbortError, // signal fired
166
+ TimeoutError, // exceeded timeoutMs
167
+ } from "@networkdiagnostics/sdk";
168
+ ```
169
+
170
+ Common `code` values: `WS_ERROR`, `WS_CLOSED`, `DC_ERROR`, `DC_CLOSED`, `SERVER_ERROR`, `TEST_STOPPED`, `BAD_STATE`, `UNKNOWN`.
171
+
172
+ ## Browser support
173
+
174
+ Evergreen Chromium, Firefox, Safari, and Edge from 2022 onward. Requires `RTCPeerConnection`, `RTCDataChannel`, `WebSocket`, and either `Image` or `fetch` with `mode: "no-cors"`. No Node, no React Native in v0.1.
175
+
176
+ ## License
177
+
178
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,415 @@
1
+ 'use strict';
2
+
3
+ // src/errors.ts
4
+ var NetworkTestsError = class extends Error {
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.name = "NetworkTestsError";
8
+ this.code = code;
9
+ }
10
+ };
11
+ var AbortError = class extends NetworkTestsError {
12
+ constructor(message = "Operation aborted") {
13
+ super("ABORTED", message);
14
+ this.name = "AbortError";
15
+ }
16
+ };
17
+ var TimeoutError = class extends NetworkTestsError {
18
+ constructor(message = "Operation timed out") {
19
+ super("TIMEOUT", message);
20
+ this.name = "TimeoutError";
21
+ }
22
+ };
23
+
24
+ // src/udp/webrtc.ts
25
+ function createPeerConnection(iceServers) {
26
+ return new RTCPeerConnection({ iceServers });
27
+ }
28
+ function setupDataChannel(pc) {
29
+ const channel = pc.createDataChannel("test", { ordered: false });
30
+ const ready = new Promise((resolve, reject) => {
31
+ const onOpen = () => {
32
+ cleanup();
33
+ resolve();
34
+ };
35
+ const onError = () => {
36
+ cleanup();
37
+ reject(new NetworkTestsError("DC_ERROR", "DataChannel error before open"));
38
+ };
39
+ const onClose = () => {
40
+ cleanup();
41
+ reject(new NetworkTestsError("DC_CLOSED", "DataChannel closed before open"));
42
+ };
43
+ function cleanup() {
44
+ channel.removeEventListener("open", onOpen);
45
+ channel.removeEventListener("error", onError);
46
+ channel.removeEventListener("close", onClose);
47
+ }
48
+ channel.addEventListener("open", onOpen);
49
+ channel.addEventListener("error", onError);
50
+ channel.addEventListener("close", onClose);
51
+ });
52
+ return { channel, ready };
53
+ }
54
+ async function createOffer(pc) {
55
+ const offer = await pc.createOffer();
56
+ await pc.setLocalDescription(offer);
57
+ return offer;
58
+ }
59
+ async function applyAnswer(pc, sdp) {
60
+ await pc.setRemoteDescription({ type: "answer", sdp });
61
+ }
62
+
63
+ // src/udp/packetLoop.ts
64
+ function startPacketLoop(opts) {
65
+ const { channel, onProgress } = opts;
66
+ const progressMs = opts.progressIntervalMs ?? 100;
67
+ const startTs = Date.now();
68
+ let received = 0;
69
+ let highestSeq = -1;
70
+ let stopped = false;
71
+ const onMsg = (ev) => {
72
+ if (stopped) return;
73
+ try {
74
+ const msg = JSON.parse(ev.data);
75
+ if (msg && msg.type === "packet" && typeof msg.seq === "number") {
76
+ received++;
77
+ if (msg.seq > highestSeq) highestSeq = msg.seq;
78
+ try {
79
+ channel.send(JSON.stringify({ type: "ack", seq: msg.seq }));
80
+ } catch {
81
+ }
82
+ }
83
+ } catch {
84
+ }
85
+ };
86
+ channel.addEventListener("message", onMsg);
87
+ let progressTimer = null;
88
+ if (onProgress) {
89
+ progressTimer = setInterval(() => {
90
+ if (stopped) return;
91
+ const sent = highestSeq + 1;
92
+ const loss = sent > 0 ? (sent - received) / sent * 100 : 0;
93
+ onProgress({
94
+ packetsSent: sent,
95
+ packetsReceived: received,
96
+ lossPercent: Math.round(loss * 100) / 100,
97
+ elapsedMs: Date.now() - startTs
98
+ });
99
+ }, progressMs);
100
+ }
101
+ return {
102
+ stop() {
103
+ if (stopped) return;
104
+ stopped = true;
105
+ channel.removeEventListener("message", onMsg);
106
+ if (progressTimer) clearInterval(progressTimer);
107
+ }
108
+ };
109
+ }
110
+
111
+ // src/udp/signaling.ts
112
+ function isServerMsg(value) {
113
+ return typeof value === "object" && value !== null && typeof value.type === "string";
114
+ }
115
+
116
+ // src/udp/index.ts
117
+ var DEFAULT_ICE_SERVERS = [
118
+ { urls: "stun:stun.l.google.com:19302" }
119
+ ];
120
+ async function runUdpTest(session, options = {}) {
121
+ const startedAt = Date.now();
122
+ const { signal, onProgress } = options;
123
+ const timeoutMs = options.timeoutMs ?? ((session.duration ?? 30) + 10) * 1e3;
124
+ if (signal?.aborted) throw new AbortError();
125
+ const ws = new WebSocket(session.wsUrl);
126
+ ws.binaryType = "arraybuffer";
127
+ let pc = null;
128
+ let dataChannel = null;
129
+ let packetLoop = null;
130
+ let timeoutHandle = null;
131
+ let abortHandler = null;
132
+ const cleanup = () => {
133
+ if (timeoutHandle) clearTimeout(timeoutHandle);
134
+ if (abortHandler && signal) signal.removeEventListener("abort", abortHandler);
135
+ packetLoop?.stop();
136
+ try {
137
+ dataChannel?.close();
138
+ } catch {
139
+ }
140
+ try {
141
+ pc?.close();
142
+ } catch {
143
+ }
144
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
145
+ try {
146
+ ws.close();
147
+ } catch {
148
+ }
149
+ }
150
+ };
151
+ return new Promise((resolve, reject) => {
152
+ const fail = (err) => {
153
+ cleanup();
154
+ reject(err);
155
+ };
156
+ const succeed = (result) => {
157
+ cleanup();
158
+ resolve(result);
159
+ };
160
+ timeoutHandle = setTimeout(
161
+ () => fail(new TimeoutError(`UDP test exceeded ${timeoutMs}ms`)),
162
+ timeoutMs
163
+ );
164
+ if (signal) {
165
+ abortHandler = () => fail(new AbortError());
166
+ signal.addEventListener("abort", abortHandler, { once: true });
167
+ }
168
+ const send = (msg) => {
169
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
170
+ };
171
+ ws.addEventListener(
172
+ "error",
173
+ () => fail(new NetworkTestsError("WS_ERROR", "WebSocket error"))
174
+ );
175
+ ws.addEventListener("close", (ev) => {
176
+ if (ws.readyState === WebSocket.CLOSED) {
177
+ const reason = ev.reason || `WebSocket closed (code ${ev.code})`;
178
+ fail(new NetworkTestsError("WS_CLOSED", reason));
179
+ }
180
+ });
181
+ ws.addEventListener("open", async () => {
182
+ });
183
+ ws.addEventListener("message", async (ev) => {
184
+ let msg;
185
+ try {
186
+ msg = JSON.parse(String(ev.data));
187
+ } catch {
188
+ return;
189
+ }
190
+ if (!isServerMsg(msg)) return;
191
+ const m = msg;
192
+ try {
193
+ switch (m.type) {
194
+ case "welcome": {
195
+ const iceServers = [
196
+ ...session.iceServers ?? DEFAULT_ICE_SERVERS
197
+ ];
198
+ if (m.turn) {
199
+ iceServers.push({
200
+ urls: m.turn.urls,
201
+ username: m.turn.username,
202
+ credential: m.turn.credential
203
+ });
204
+ }
205
+ pc = createPeerConnection(iceServers);
206
+ pc.addEventListener("icecandidate", (e) => {
207
+ if (e.candidate) {
208
+ send({ type: "ice-candidate", candidate: e.candidate.toJSON() });
209
+ }
210
+ });
211
+ const dcSetup = setupDataChannel(pc);
212
+ dataChannel = dcSetup.channel;
213
+ const offer = await createOffer(pc);
214
+ send({ type: "offer", sdp: offer.sdp ?? "" });
215
+ dcSetup.ready.then(() => send({ type: "datachannel-ready" })).catch((err) => fail(toError(err)));
216
+ break;
217
+ }
218
+ case "answer": {
219
+ if (!pc) throw new NetworkTestsError("BAD_STATE", "answer before welcome");
220
+ await applyAnswer(pc, m.sdp.sdp);
221
+ break;
222
+ }
223
+ case "ice-candidate": {
224
+ if (!pc) return;
225
+ try {
226
+ await pc.addIceCandidate(m.candidate);
227
+ } catch {
228
+ }
229
+ break;
230
+ }
231
+ case "datachannel-open": {
232
+ if (!dataChannel) throw new NetworkTestsError("BAD_STATE", "no DC");
233
+ packetLoop = startPacketLoop({ channel: dataChannel, onProgress });
234
+ send({
235
+ type: "start-packet-test",
236
+ duration: session.duration,
237
+ rate: session.rate,
238
+ packetSize: session.packetSize
239
+ });
240
+ break;
241
+ }
242
+ case "test-started":
243
+ break;
244
+ case "test-complete": {
245
+ const r = m.results;
246
+ succeed({
247
+ sessionId: session.sessionId,
248
+ packetsSent: r.packetsSent,
249
+ packetsReceived: r.packetsReceived,
250
+ packetsLost: r.packetsLost,
251
+ lossPercent: r.lossPercent,
252
+ jitter: r.jitter,
253
+ latency: {
254
+ avg: r.latency.avg,
255
+ min: r.latency.min,
256
+ max: r.latency.max,
257
+ p50: r.latency.p50,
258
+ p95: r.latency.p95,
259
+ samples: r.latency.samples
260
+ },
261
+ durationMs: Date.now() - startedAt,
262
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
263
+ });
264
+ break;
265
+ }
266
+ case "test-stopped":
267
+ fail(new NetworkTestsError("TEST_STOPPED", "Server stopped the test"));
268
+ break;
269
+ case "error":
270
+ fail(new NetworkTestsError("SERVER_ERROR", m.message));
271
+ break;
272
+ }
273
+ } catch (err) {
274
+ fail(toError(err));
275
+ }
276
+ });
277
+ });
278
+ }
279
+ function toError(value) {
280
+ if (value instanceof Error) return value;
281
+ return new NetworkTestsError("UNKNOWN", String(value));
282
+ }
283
+
284
+ // src/dnsleak/trigger.ts
285
+ var ABORT_AFTER_MS = 5e3;
286
+ function triggerDnsResolution(fqdn, signal) {
287
+ if (signal?.aborted) return Promise.resolve();
288
+ if (typeof Image !== "undefined") {
289
+ return triggerViaImage(fqdn, signal);
290
+ }
291
+ return triggerViaFetch(fqdn, signal);
292
+ }
293
+ function triggerViaImage(fqdn, signal) {
294
+ return new Promise((resolve) => {
295
+ const img = new Image();
296
+ let settled = false;
297
+ const finish = () => {
298
+ if (settled) return;
299
+ settled = true;
300
+ img.src = "";
301
+ resolve();
302
+ };
303
+ const onAbort = () => finish();
304
+ signal?.addEventListener("abort", onAbort, { once: true });
305
+ const timer = setTimeout(finish, ABORT_AFTER_MS);
306
+ img.onload = () => {
307
+ clearTimeout(timer);
308
+ finish();
309
+ };
310
+ img.onerror = () => {
311
+ clearTimeout(timer);
312
+ finish();
313
+ };
314
+ img.src = `https://${fqdn}/_/leak.gif?t=${Date.now()}-${Math.random().toString(36).slice(2)}`;
315
+ });
316
+ }
317
+ function triggerViaFetch(fqdn, signal) {
318
+ const controller = new AbortController();
319
+ const abortSignal = signal ? mergeSignals(signal, controller.signal) : controller.signal;
320
+ const timer = setTimeout(() => controller.abort(), ABORT_AFTER_MS);
321
+ return fetch(`https://${fqdn}/_/leak?t=${Date.now()}`, {
322
+ method: "GET",
323
+ mode: "no-cors",
324
+ cache: "no-store",
325
+ signal: abortSignal
326
+ }).catch(() => void 0).finally(() => clearTimeout(timer)).then(() => void 0);
327
+ }
328
+ function mergeSignals(a, b) {
329
+ const c = new AbortController();
330
+ const onAbort = () => c.abort();
331
+ if (a.aborted || b.aborted) c.abort();
332
+ else {
333
+ a.addEventListener("abort", onAbort, { once: true });
334
+ b.addEventListener("abort", onAbort, { once: true });
335
+ }
336
+ return c.signal;
337
+ }
338
+
339
+ // src/dnsleak/index.ts
340
+ async function runDnsLeakTest(session, options = {}) {
341
+ if (options.signal?.aborted) throw new AbortError();
342
+ const timeoutMs = options.timeoutMs ?? 15e3;
343
+ let timeoutHandle = null;
344
+ const timeoutController = new AbortController();
345
+ const combinedSignal = mergeSignals2(options.signal, timeoutController.signal);
346
+ const timeoutPromise = new Promise((_, reject) => {
347
+ timeoutHandle = setTimeout(() => {
348
+ timeoutController.abort();
349
+ reject(new TimeoutError(`DNS-leak test exceeded ${timeoutMs}ms`));
350
+ }, timeoutMs);
351
+ });
352
+ const abortPromise = new Promise((_, reject) => {
353
+ options.signal?.addEventListener(
354
+ "abort",
355
+ () => reject(new AbortError()),
356
+ { once: true }
357
+ );
358
+ });
359
+ const triggers = session.fqdns.map(
360
+ (fqdn) => triggerDnsResolution(fqdn, combinedSignal).then(() => {
361
+ options.onResolved?.(fqdn);
362
+ })
363
+ );
364
+ try {
365
+ await Promise.race([
366
+ Promise.all(triggers),
367
+ timeoutPromise,
368
+ abortPromise
369
+ ]);
370
+ } finally {
371
+ if (timeoutHandle) clearTimeout(timeoutHandle);
372
+ }
373
+ }
374
+ function mergeSignals2(a, b) {
375
+ if (!a) return b;
376
+ const c = new AbortController();
377
+ if (a.aborted || b.aborted) c.abort();
378
+ else {
379
+ a.addEventListener("abort", () => c.abort(), { once: true });
380
+ b.addEventListener("abort", () => c.abort(), { once: true });
381
+ }
382
+ return c.signal;
383
+ }
384
+
385
+ // src/client.ts
386
+ var NetworkTests = class {
387
+ /**
388
+ * Run the UDP throughput probe to completion.
389
+ *
390
+ * Opens a WebSocket to `session.wsUrl`, negotiates a WebRTC DataChannel,
391
+ * runs the packet-loss test, and resolves with the final stats once the
392
+ * server emits `test-complete`.
393
+ */
394
+ runUdpTest(session, options) {
395
+ return runUdpTest(session, options);
396
+ }
397
+ /**
398
+ * Trigger DNS resolution of every FQDN in the session. Returns once all
399
+ * triggers have fired (or errored — errors are expected because the
400
+ * fake server returns 192.0.2.1).
401
+ *
402
+ * Actual resolver IPs are observed server-side. Customer's backend
403
+ * polls `GET /v1/probe/dns-leak/sessions/:id` to retrieve them.
404
+ */
405
+ runDnsLeakTest(session, options) {
406
+ return runDnsLeakTest(session, options);
407
+ }
408
+ };
409
+
410
+ exports.AbortError = AbortError;
411
+ exports.NetworkTests = NetworkTests;
412
+ exports.NetworkTestsError = NetworkTestsError;
413
+ exports.TimeoutError = TimeoutError;
414
+ //# sourceMappingURL=index.cjs.map
415
+ //# sourceMappingURL=index.cjs.map