rn-network-quality 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +899 -0
  3. package/android/build.gradle +60 -0
  4. package/android/src/main/AndroidManifest.xml +3 -0
  5. package/android/src/main/java/com/rnnetworkquality/CellularInfo.kt +34 -0
  6. package/android/src/main/java/com/rnnetworkquality/DownloadPolicy.kt +26 -0
  7. package/android/src/main/java/com/rnnetworkquality/Mappers.kt +106 -0
  8. package/android/src/main/java/com/rnnetworkquality/NetworkMonitor.kt +263 -0
  9. package/android/src/main/java/com/rnnetworkquality/NetworkProbe.kt +668 -0
  10. package/android/src/main/java/com/rnnetworkquality/NetworkQualityModule.kt +287 -0
  11. package/android/src/main/java/com/rnnetworkquality/NetworkQualityPackage.kt +25 -0
  12. package/android/src/main/java/com/rnnetworkquality/NetworkSnapshot.kt +83 -0
  13. package/android/src/main/java/com/rnnetworkquality/SnapshotBuilder.kt +92 -0
  14. package/android/src/main/java/com/rnnetworkquality/Throttler.kt +100 -0
  15. package/ios/CellularInfo.swift +47 -0
  16. package/ios/NetworkProbe.swift +391 -0
  17. package/ios/NetworkQuality.h +8 -0
  18. package/ios/NetworkQuality.mm +88 -0
  19. package/ios/NetworkQualityImpl.swift +371 -0
  20. package/ios/PathSnapshot.swift +109 -0
  21. package/ios/PrivacyInfo.xcprivacy +23 -0
  22. package/ios/Throttler.swift +174 -0
  23. package/lib/module/NativeNetworkQuality.js +5 -0
  24. package/lib/module/NativeNetworkQuality.js.map +1 -0
  25. package/lib/module/classify.js +126 -0
  26. package/lib/module/classify.js.map +1 -0
  27. package/lib/module/constants.js +52 -0
  28. package/lib/module/constants.js.map +1 -0
  29. package/lib/module/errors.js +18 -0
  30. package/lib/module/errors.js.map +1 -0
  31. package/lib/module/hooks.js +78 -0
  32. package/lib/module/hooks.js.map +1 -0
  33. package/lib/module/index.js +19 -0
  34. package/lib/module/index.js.map +1 -0
  35. package/lib/module/manager.js +595 -0
  36. package/lib/module/manager.js.map +1 -0
  37. package/lib/module/normalize.js +35 -0
  38. package/lib/module/normalize.js.map +1 -0
  39. package/lib/module/package.json +1 -0
  40. package/lib/module/types.js +2 -0
  41. package/lib/module/types.js.map +1 -0
  42. package/lib/typescript/package.json +1 -0
  43. package/lib/typescript/src/NativeNetworkQuality.d.ts +50 -0
  44. package/lib/typescript/src/NativeNetworkQuality.d.ts.map +1 -0
  45. package/lib/typescript/src/classify.d.ts +12 -0
  46. package/lib/typescript/src/classify.d.ts.map +1 -0
  47. package/lib/typescript/src/constants.d.ts +15 -0
  48. package/lib/typescript/src/constants.d.ts.map +1 -0
  49. package/lib/typescript/src/errors.d.ts +13 -0
  50. package/lib/typescript/src/errors.d.ts.map +1 -0
  51. package/lib/typescript/src/hooks.d.ts +18 -0
  52. package/lib/typescript/src/hooks.d.ts.map +1 -0
  53. package/lib/typescript/src/index.d.ts +13 -0
  54. package/lib/typescript/src/index.d.ts.map +1 -0
  55. package/lib/typescript/src/manager.d.ts +98 -0
  56. package/lib/typescript/src/manager.d.ts.map +1 -0
  57. package/lib/typescript/src/normalize.d.ts +5 -0
  58. package/lib/typescript/src/normalize.d.ts.map +1 -0
  59. package/lib/typescript/src/types.d.ts +171 -0
  60. package/lib/typescript/src/types.d.ts.map +1 -0
  61. package/mock.js +318 -0
  62. package/package.json +161 -0
  63. package/rn-network-quality.podspec +32 -0
  64. package/src/NativeNetworkQuality.ts +58 -0
  65. package/src/classify.ts +208 -0
  66. package/src/constants.ts +48 -0
  67. package/src/errors.ts +23 -0
  68. package/src/hooks.ts +110 -0
  69. package/src/index.tsx +25 -0
  70. package/src/manager.ts +891 -0
  71. package/src/normalize.ts +81 -0
  72. package/src/types.ts +207 -0
package/src/manager.ts ADDED
@@ -0,0 +1,891 @@
1
+ import { AppState, type AppStateStatus } from 'react-native';
2
+
3
+ import NativeNetworkQuality, {
4
+ type NativeMonitorOptions,
5
+ type NativeNetworkSnapshot,
6
+ type NativeProbeOptions,
7
+ type NativeProbeResult,
8
+ } from './NativeNetworkQuality';
9
+ import { classifyNetworkQuality } from './classify';
10
+ import { DEFAULT_CONFIG } from './constants';
11
+ import { NetworkQualityError } from './errors';
12
+ import { normalizeSnapshot } from './normalize';
13
+ import type {
14
+ DeepPartial,
15
+ NetworkQualityConfig,
16
+ NetworkQualityErrorCode,
17
+ NetworkQualityState,
18
+ NetworkSnapshot,
19
+ ProbeFailure,
20
+ ProbeConfig,
21
+ ProbeResult,
22
+ } from './types';
23
+
24
+ const MIN_AUTO_PROBE_INTERVAL_MS = 15_000;
25
+ const TRANSPORT_CHANGE_DEBOUNCE_MS = 2_000;
26
+ const MAX_LATENCY_SAMPLES = 100;
27
+ const MAX_NATIVE_TIMEOUT_MS = 2_147_483_647;
28
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
29
+ const SUPPORTED_ERROR_CODES = new Set<NetworkQualityErrorCode>([
30
+ 'E_UNSUPPORTED',
31
+ 'E_OFFLINE',
32
+ 'E_INVALID_URL',
33
+ 'E_PROBE_TIMEOUT',
34
+ 'E_PROBE_FAILED',
35
+ 'E_PROBE_SKIPPED',
36
+ ]);
37
+
38
+ type RemovableSubscription = { remove(): void };
39
+
40
+ /** Minimal native surface consumed by the TypeScript manager. */
41
+ export interface NetworkQualityNativeModule {
42
+ getCurrentState(): Promise<NativeNetworkSnapshot>;
43
+ startMonitoring(options: NativeMonitorOptions): void;
44
+ stopMonitoring(): void;
45
+ probe(options: NativeProbeOptions): Promise<NativeProbeResult>;
46
+ onNetworkStateChange(
47
+ listener: (snapshot: NativeNetworkSnapshot) => void
48
+ ): RemovableSubscription;
49
+ }
50
+
51
+ /** Minimal AppState surface consumed by the TypeScript manager. */
52
+ export interface NetworkQualityAppState {
53
+ readonly currentState: string | null | undefined;
54
+ addEventListener(
55
+ type: 'change',
56
+ listener: (state: AppStateStatus) => void
57
+ ): RemovableSubscription;
58
+ }
59
+
60
+ interface ListenerRecord {
61
+ active: boolean;
62
+ listener: (state: NetworkQualityState) => void;
63
+ }
64
+
65
+ interface ErrorLike {
66
+ code?: unknown;
67
+ message?: unknown;
68
+ }
69
+
70
+ function cloneProbeConfig(config: ProbeConfig): ProbeConfig {
71
+ return { ...config };
72
+ }
73
+
74
+ function cloneConfig(config: NetworkQualityConfig): NetworkQualityConfig {
75
+ return {
76
+ ...config,
77
+ thresholds: {
78
+ excellent: { ...config.thresholds.excellent },
79
+ good: { ...config.thresholds.good },
80
+ moderate: { ...config.thresholds.moderate },
81
+ },
82
+ probe: cloneProbeConfig(config.probe),
83
+ autoProbe: { ...config.autoProbe },
84
+ };
85
+ }
86
+
87
+ function mergeConfig(
88
+ current: NetworkQualityConfig,
89
+ patch: DeepPartial<NetworkQualityConfig>
90
+ ): NetworkQualityConfig {
91
+ const valueOrCurrent = <T>(value: T | undefined, currentValue: T): T =>
92
+ value === undefined ? currentValue : value;
93
+
94
+ return {
95
+ throttleMs: valueOrCurrent(patch.throttleMs, current.throttleMs),
96
+ bandwidthChangeThresholdPct: valueOrCurrent(
97
+ patch.bandwidthChangeThresholdPct,
98
+ current.bandwidthChangeThresholdPct
99
+ ),
100
+ validationGraceMs: valueOrCurrent(
101
+ patch.validationGraceMs,
102
+ current.validationGraceMs
103
+ ),
104
+ thresholds: {
105
+ excellent: {
106
+ minDownlinkKbps: valueOrCurrent(
107
+ patch.thresholds?.excellent?.minDownlinkKbps,
108
+ current.thresholds.excellent.minDownlinkKbps
109
+ ),
110
+ maxRttMs: valueOrCurrent(
111
+ patch.thresholds?.excellent?.maxRttMs,
112
+ current.thresholds.excellent.maxRttMs
113
+ ),
114
+ },
115
+ good: {
116
+ minDownlinkKbps: valueOrCurrent(
117
+ patch.thresholds?.good?.minDownlinkKbps,
118
+ current.thresholds.good.minDownlinkKbps
119
+ ),
120
+ maxRttMs: valueOrCurrent(
121
+ patch.thresholds?.good?.maxRttMs,
122
+ current.thresholds.good.maxRttMs
123
+ ),
124
+ },
125
+ moderate: {
126
+ minDownlinkKbps: valueOrCurrent(
127
+ patch.thresholds?.moderate?.minDownlinkKbps,
128
+ current.thresholds.moderate.minDownlinkKbps
129
+ ),
130
+ maxRttMs: valueOrCurrent(
131
+ patch.thresholds?.moderate?.maxRttMs,
132
+ current.thresholds.moderate.maxRttMs
133
+ ),
134
+ },
135
+ },
136
+ probe: {
137
+ latencyUrl: valueOrCurrent(
138
+ patch.probe?.latencyUrl,
139
+ current.probe.latencyUrl
140
+ ),
141
+ downloadUrl: valueOrCurrent(
142
+ patch.probe?.downloadUrl,
143
+ current.probe.downloadUrl
144
+ ),
145
+ latencySamples: valueOrCurrent(
146
+ patch.probe?.latencySamples,
147
+ current.probe.latencySamples
148
+ ),
149
+ timeoutMs: valueOrCurrent(
150
+ patch.probe?.timeoutMs,
151
+ current.probe.timeoutMs
152
+ ),
153
+ downloadMaxDurationMs: valueOrCurrent(
154
+ patch.probe?.downloadMaxDurationMs,
155
+ current.probe.downloadMaxDurationMs
156
+ ),
157
+ downloadMaxBytes: valueOrCurrent(
158
+ patch.probe?.downloadMaxBytes,
159
+ current.probe.downloadMaxBytes
160
+ ),
161
+ resultTtlMs: valueOrCurrent(
162
+ patch.probe?.resultTtlMs,
163
+ current.probe.resultTtlMs
164
+ ),
165
+ },
166
+ autoProbe: {
167
+ enabled: valueOrCurrent(
168
+ patch.autoProbe?.enabled,
169
+ current.autoProbe.enabled
170
+ ),
171
+ intervalMs: valueOrCurrent(
172
+ patch.autoProbe?.intervalMs,
173
+ current.autoProbe.intervalMs
174
+ ),
175
+ onTransportChange: valueOrCurrent(
176
+ patch.autoProbe?.onTransportChange,
177
+ current.autoProbe.onTransportChange
178
+ ),
179
+ allowOnExpensive: valueOrCurrent(
180
+ patch.autoProbe?.allowOnExpensive,
181
+ current.autoProbe.allowOnExpensive
182
+ ),
183
+ allowOnConstrained: valueOrCurrent(
184
+ patch.autoProbe?.allowOnConstrained,
185
+ current.autoProbe.allowOnConstrained
186
+ ),
187
+ },
188
+ };
189
+ }
190
+
191
+ function assertFiniteNumber(
192
+ value: number,
193
+ path: string,
194
+ minimum: number,
195
+ exclusiveMinimum = false
196
+ ): void {
197
+ const belowMinimum = exclusiveMinimum ? value <= minimum : value < minimum;
198
+ if (!Number.isFinite(value) || belowMinimum) {
199
+ const operator = exclusiveMinimum ? 'greater than' : 'at least';
200
+ throw new TypeError(
201
+ `${path} must be a finite number ${operator} ${minimum}`
202
+ );
203
+ }
204
+ }
205
+
206
+ function validateProbeConfig(config: ProbeConfig): void {
207
+ if (typeof config.latencyUrl !== 'string' || config.latencyUrl.length === 0) {
208
+ throw new TypeError('probe.latencyUrl must be a non-empty string');
209
+ }
210
+ if (
211
+ config.downloadUrl !== null &&
212
+ (typeof config.downloadUrl !== 'string' || config.downloadUrl.length === 0)
213
+ ) {
214
+ throw new TypeError('probe.downloadUrl must be null or a non-empty string');
215
+ }
216
+ if (
217
+ !Number.isInteger(config.latencySamples) ||
218
+ config.latencySamples < 1 ||
219
+ config.latencySamples > MAX_LATENCY_SAMPLES
220
+ ) {
221
+ throw new TypeError(
222
+ `probe.latencySamples must be an integer between 1 and ${MAX_LATENCY_SAMPLES}`
223
+ );
224
+ }
225
+ assertFiniteNumber(config.timeoutMs, 'probe.timeoutMs', 0, true);
226
+ if (config.timeoutMs > MAX_NATIVE_TIMEOUT_MS) {
227
+ throw new TypeError(
228
+ `probe.timeoutMs must be at most ${MAX_NATIVE_TIMEOUT_MS}`
229
+ );
230
+ }
231
+ assertFiniteNumber(
232
+ config.downloadMaxDurationMs,
233
+ 'probe.downloadMaxDurationMs',
234
+ 0,
235
+ true
236
+ );
237
+ if (config.downloadMaxDurationMs > MAX_NATIVE_TIMEOUT_MS) {
238
+ throw new TypeError(
239
+ `probe.downloadMaxDurationMs must be at most ${MAX_NATIVE_TIMEOUT_MS}`
240
+ );
241
+ }
242
+ if (
243
+ !Number.isInteger(config.downloadMaxBytes) ||
244
+ config.downloadMaxBytes <= 0 ||
245
+ config.downloadMaxBytes > MAX_NATIVE_TIMEOUT_MS
246
+ ) {
247
+ throw new TypeError(
248
+ `probe.downloadMaxBytes must be an integer between 1 and ${MAX_NATIVE_TIMEOUT_MS}`
249
+ );
250
+ }
251
+ assertFiniteNumber(config.resultTtlMs, 'probe.resultTtlMs', 0);
252
+ }
253
+
254
+ function validateConfig(config: NetworkQualityConfig): NetworkQualityConfig {
255
+ assertFiniteNumber(config.throttleMs, 'throttleMs', 0);
256
+ assertFiniteNumber(
257
+ config.bandwidthChangeThresholdPct,
258
+ 'bandwidthChangeThresholdPct',
259
+ 0
260
+ );
261
+ assertFiniteNumber(config.validationGraceMs, 'validationGraceMs', 0);
262
+ if (config.validationGraceMs > MAX_TIMER_DELAY_MS) {
263
+ throw new TypeError(
264
+ `validationGraceMs must be at most ${MAX_TIMER_DELAY_MS}`
265
+ );
266
+ }
267
+
268
+ const { excellent, good, moderate } = config.thresholds;
269
+ for (const [name, threshold] of Object.entries(config.thresholds)) {
270
+ assertFiniteNumber(
271
+ threshold.minDownlinkKbps,
272
+ `thresholds.${name}.minDownlinkKbps`,
273
+ 0
274
+ );
275
+ assertFiniteNumber(threshold.maxRttMs, `thresholds.${name}.maxRttMs`, 0);
276
+ }
277
+ if (
278
+ excellent.minDownlinkKbps < good.minDownlinkKbps ||
279
+ good.minDownlinkKbps < moderate.minDownlinkKbps ||
280
+ excellent.maxRttMs > good.maxRttMs ||
281
+ good.maxRttMs > moderate.maxRttMs
282
+ ) {
283
+ throw new TypeError(
284
+ 'thresholds must be monotonic from excellent to good to moderate'
285
+ );
286
+ }
287
+
288
+ validateProbeConfig(config.probe);
289
+ assertFiniteNumber(config.autoProbe.intervalMs, 'autoProbe.intervalMs', 0);
290
+ for (const [name, value] of Object.entries(config.autoProbe)) {
291
+ if (name !== 'intervalMs' && typeof value !== 'boolean') {
292
+ throw new TypeError(`autoProbe.${name} must be a boolean`);
293
+ }
294
+ }
295
+
296
+ return {
297
+ ...config,
298
+ autoProbe: {
299
+ ...config.autoProbe,
300
+ intervalMs: Math.max(
301
+ MIN_AUTO_PROBE_INTERVAL_MS,
302
+ config.autoProbe.intervalMs
303
+ ),
304
+ },
305
+ };
306
+ }
307
+
308
+ function stateEqualsIgnoringSnapshotTimestamp(
309
+ first: NetworkQualityState,
310
+ second: NetworkQualityState
311
+ ): boolean {
312
+ return (
313
+ JSON.stringify({ ...first, timestamp: 0 }) ===
314
+ JSON.stringify({ ...second, timestamp: 0 })
315
+ );
316
+ }
317
+
318
+ function errorMessage(error: unknown): string {
319
+ if (
320
+ typeof error === 'object' &&
321
+ error !== null &&
322
+ typeof (error as ErrorLike).message === 'string'
323
+ ) {
324
+ return (error as ErrorLike).message as string;
325
+ }
326
+ return typeof error === 'string' ? error : 'The network probe failed';
327
+ }
328
+
329
+ function wrapProbeError(error: unknown): NetworkQualityError {
330
+ if (error instanceof NetworkQualityError) return error;
331
+
332
+ const candidate =
333
+ typeof error === 'object' && error !== null
334
+ ? (error as ErrorLike).code
335
+ : undefined;
336
+ const code =
337
+ typeof candidate === 'string' &&
338
+ SUPPORTED_ERROR_CODES.has(candidate as NetworkQualityErrorCode)
339
+ ? (candidate as NetworkQualityErrorCode)
340
+ : 'E_PROBE_FAILED';
341
+ return new NetworkQualityError(code, errorMessage(error), { cause: error });
342
+ }
343
+
344
+ function unsupportedError(): NetworkQualityError {
345
+ return new NetworkQualityError(
346
+ 'E_UNSUPPORTED',
347
+ 'rn-network-quality is unavailable. Run pod install on iOS, enable the React Native New Architecture, and rebuild the app. Expo Go is not supported; use an Expo development build.'
348
+ );
349
+ }
350
+
351
+ /** Coordinates the native module, cached state, listeners, and auto-probes. */
352
+ export class NetworkQualityManager {
353
+ private readonly nativeModule: NetworkQualityNativeModule | null;
354
+ private readonly appStateApi: NetworkQualityAppState;
355
+ private config = cloneConfig(DEFAULT_CONFIG);
356
+ private readonly listeners = new Set<ListenerRecord>();
357
+ private nativeSubscription: RemovableSubscription | null = null;
358
+ private appStateSubscription: RemovableSubscription | null = null;
359
+ private latestSnapshot: NetworkSnapshot | null = null;
360
+ private cachedState: NetworkQualityState | null = null;
361
+ private lastProbe: ProbeResult | null = null;
362
+ private lastProbeFailure: ProbeFailure | null = null;
363
+ private lastProbeResultTtlMs: number | null = null;
364
+ private lastProbeFailureTtlMs: number | null = null;
365
+ private networkChangedAt: number | null = null;
366
+ private inFlightProbe: Promise<ProbeResult> | null = null;
367
+ private probeExpiry: ReturnType<typeof setTimeout> | null = null;
368
+ private validationGraceExpiry: ReturnType<typeof setTimeout> | null = null;
369
+ private autoProbeInterval: ReturnType<typeof setInterval> | null = null;
370
+ private transportDebounce: ReturnType<typeof setTimeout> | null = null;
371
+ private appStateStatus: string | null | undefined;
372
+ private monitoringStopVersion = 0;
373
+ private stateVersion = 0;
374
+
375
+ public constructor(
376
+ nativeModule: NetworkQualityNativeModule | null = NativeNetworkQuality ??
377
+ null,
378
+ appStateApi: NetworkQualityAppState = AppState
379
+ ) {
380
+ this.nativeModule = nativeModule;
381
+ this.appStateApi = appStateApi;
382
+ this.appStateStatus = appStateApi.currentState;
383
+ }
384
+
385
+ /** Returns whether the native TurboModule is linked and available. */
386
+ public isSupported(): boolean {
387
+ return this.nativeModule !== null;
388
+ }
389
+
390
+ /** Deep-merges and validates runtime monitoring and probe configuration. */
391
+ public configure(patch: DeepPartial<NetworkQualityConfig>): void {
392
+ const nativeModule = this.requireNativeModule();
393
+ const nextConfig = validateConfig(mergeConfig(this.config, patch));
394
+ this.config = cloneConfig(nextConfig);
395
+ this.reclassifyCachedSnapshot();
396
+ this.scheduleValidationGraceExpiry();
397
+
398
+ if (this.listeners.size > 0) {
399
+ this.refreshAutoProbeLifecycle();
400
+ nativeModule.startMonitoring(this.nativeMonitorOptions());
401
+ }
402
+ }
403
+
404
+ /** Returns a defensive copy of the active runtime configuration. */
405
+ public getConfig(): NetworkQualityConfig {
406
+ this.requireNativeModule();
407
+ return cloneConfig(this.config);
408
+ }
409
+
410
+ /** Fetches, normalizes, classifies, and caches the current native snapshot. */
411
+ public getNetworkQuality(): Promise<NetworkQualityState> {
412
+ const nativeModule = this.requireNativeModule();
413
+ return nativeModule
414
+ .getCurrentState()
415
+ .then((snapshot) => this.acceptNativeSnapshot(snapshot));
416
+ }
417
+
418
+ /** Adds a live-state listener and starts native monitoring when necessary. */
419
+ public addNetworkQualityListener(
420
+ listener: (state: NetworkQualityState) => void
421
+ ): RemovableSubscription {
422
+ const nativeModule = this.requireNativeModule();
423
+ if (this.listeners.size === 0) this.reclassifyCachedSnapshot();
424
+ const record: ListenerRecord = { active: true, listener };
425
+ const hadCachedState = this.cachedState !== null;
426
+ const versionAtSubscription = this.stateVersion;
427
+ this.listeners.add(record);
428
+
429
+ if (this.listeners.size === 1) {
430
+ try {
431
+ this.nativeSubscription = nativeModule.onNetworkStateChange(
432
+ (snapshot) => {
433
+ this.acceptNativeSnapshot(snapshot);
434
+ }
435
+ );
436
+ this.scheduleProbeExpiry();
437
+ this.scheduleValidationGraceExpiry();
438
+ this.refreshAutoProbeLifecycle();
439
+ nativeModule.startMonitoring(this.nativeMonitorOptions());
440
+ } catch (error) {
441
+ this.nativeSubscription?.remove();
442
+ this.nativeSubscription = null;
443
+ this.listeners.delete(record);
444
+ this.clearProbeExpiry();
445
+ this.clearValidationGraceExpiry();
446
+ this.stopAutoProbeLifecycle();
447
+ throw error;
448
+ }
449
+ }
450
+
451
+ if (hadCachedState) {
452
+ queueMicrotask(() => {
453
+ if (
454
+ record.active &&
455
+ this.cachedState !== null &&
456
+ this.stateVersion === versionAtSubscription
457
+ ) {
458
+ record.listener(this.cachedState);
459
+ }
460
+ });
461
+ }
462
+
463
+ return {
464
+ remove: () => {
465
+ if (!record.active) return;
466
+ record.active = false;
467
+ this.listeners.delete(record);
468
+ if (this.listeners.size === 0) {
469
+ this.nativeSubscription?.remove();
470
+ this.nativeSubscription = null;
471
+ this.clearProbeExpiry();
472
+ this.clearValidationGraceExpiry();
473
+ this.stopAutoProbeLifecycle();
474
+ this.monitoringStopVersion += 1;
475
+ nativeModule.stopMonitoring();
476
+ }
477
+ },
478
+ };
479
+ }
480
+
481
+ /** Runs one active probe, de-duplicating concurrent calls. */
482
+ public probeNetwork(
483
+ options: Partial<ProbeConfig> = {}
484
+ ): Promise<ProbeResult> {
485
+ const nativeModule = this.requireNativeModule();
486
+ if (this.inFlightProbe !== null) return this.inFlightProbe;
487
+
488
+ const probeConfig = { ...this.config.probe, ...options };
489
+ validateProbeConfig(probeConfig);
490
+ const nativeOptions: NativeProbeOptions = {
491
+ latencyUrl: probeConfig.latencyUrl,
492
+ downloadUrl: probeConfig.downloadUrl,
493
+ latencySamples: probeConfig.latencySamples,
494
+ timeoutMs: probeConfig.timeoutMs,
495
+ downloadMaxDurationMs: probeConfig.downloadMaxDurationMs,
496
+ downloadMaxBytes: probeConfig.downloadMaxBytes,
497
+ };
498
+ const stopVersionAtStart = this.monitoringStopVersion;
499
+
500
+ let transport: ProbeResult['transport'] = 'unknown';
501
+ let nativeProbe: Promise<NativeProbeResult>;
502
+ try {
503
+ if (this.latestSnapshot !== null) {
504
+ transport = this.latestSnapshot.transport;
505
+ nativeProbe = nativeModule.probe(nativeOptions);
506
+ } else {
507
+ nativeProbe = nativeModule.getCurrentState().then((snapshot) => {
508
+ if (this.monitoringStopVersion !== stopVersionAtStart) {
509
+ throw new NetworkQualityError(
510
+ 'E_PROBE_FAILED',
511
+ 'The network probe was cancelled because monitoring stopped.'
512
+ );
513
+ }
514
+ transport = this.acceptNativeSnapshot(snapshot).transport;
515
+ return nativeModule.probe(nativeOptions);
516
+ });
517
+ }
518
+ } catch (error) {
519
+ const wrapped = wrapProbeError(error);
520
+ this.recordProbeFailure(wrapped, transport, probeConfig.resultTtlMs);
521
+ throw wrapped;
522
+ }
523
+
524
+ let managedProbe: Promise<ProbeResult>;
525
+ managedProbe = nativeProbe
526
+ .then((result) => {
527
+ const probeResult: ProbeResult = { ...result, transport };
528
+ this.lastProbe = probeResult;
529
+ this.lastProbeFailure = null;
530
+ this.lastProbeFailureTtlMs = null;
531
+ this.lastProbeResultTtlMs = probeConfig.resultTtlMs;
532
+ this.reclassifyCachedSnapshot();
533
+ this.scheduleProbeExpiry();
534
+ return probeResult;
535
+ })
536
+ .catch((error: unknown) => {
537
+ const wrapped = wrapProbeError(error);
538
+ this.recordProbeFailure(wrapped, transport, probeConfig.resultTtlMs);
539
+ throw wrapped;
540
+ })
541
+ .finally(() => {
542
+ if (this.inFlightProbe === managedProbe) this.inFlightProbe = null;
543
+ });
544
+ this.inFlightProbe = managedProbe;
545
+ return managedProbe;
546
+ }
547
+
548
+ /** Returns the most recent successful active-probe result. */
549
+ public getLastProbeResult(): ProbeResult | null {
550
+ this.requireNativeModule();
551
+ return this.lastProbe;
552
+ }
553
+
554
+ /** Returns the referentially stable state snapshot used by React. */
555
+ public getCachedState(): NetworkQualityState | null {
556
+ return this.cachedState;
557
+ }
558
+
559
+ private requireNativeModule(): NetworkQualityNativeModule {
560
+ if (this.nativeModule === null) throw unsupportedError();
561
+ return this.nativeModule;
562
+ }
563
+
564
+ private nativeMonitorOptions(): NativeMonitorOptions {
565
+ return {
566
+ throttleMs: this.config.throttleMs,
567
+ bandwidthChangeThresholdPct: this.config.bandwidthChangeThresholdPct,
568
+ };
569
+ }
570
+
571
+ private acceptNativeSnapshot(
572
+ nativeSnapshot: NativeNetworkSnapshot
573
+ ): NetworkQualityState {
574
+ const snapshot = normalizeSnapshot(nativeSnapshot);
575
+ const previousSnapshot = this.latestSnapshot;
576
+ const previousTransport = previousSnapshot?.transport;
577
+ const networkChanged =
578
+ previousSnapshot === null ||
579
+ previousSnapshot.isConnected !== snapshot.isConnected ||
580
+ previousSnapshot.transport !== snapshot.transport;
581
+ const transportChanged =
582
+ previousSnapshot !== null &&
583
+ previousSnapshot.transport !== snapshot.transport;
584
+ if (networkChanged) this.networkChangedAt = Date.now();
585
+ if (transportChanged) {
586
+ this.lastProbeFailure = null;
587
+ this.lastProbeFailureTtlMs = null;
588
+ }
589
+ this.latestSnapshot = snapshot;
590
+ const state = this.buildState(snapshot);
591
+ const acceptedState = this.acceptState(state);
592
+ this.scheduleProbeExpiry();
593
+ this.scheduleValidationGraceExpiry();
594
+
595
+ if (
596
+ previousTransport !== undefined &&
597
+ previousTransport !== snapshot.transport
598
+ ) {
599
+ this.scheduleTransportChangeProbe();
600
+ }
601
+ return acceptedState;
602
+ }
603
+
604
+ private buildState(snapshot: NetworkSnapshot): NetworkQualityState {
605
+ const classifierConfig =
606
+ this.lastProbeResultTtlMs === null
607
+ ? this.config
608
+ : {
609
+ ...this.config,
610
+ probe: {
611
+ ...this.config.probe,
612
+ resultTtlMs: this.lastProbeResultTtlMs,
613
+ },
614
+ };
615
+ return {
616
+ ...snapshot,
617
+ ...classifyNetworkQuality(
618
+ snapshot,
619
+ this.lastProbe,
620
+ classifierConfig,
621
+ Date.now(),
622
+ {
623
+ lastProbeFailure: this.lastProbeFailure,
624
+ networkChangedAt: this.networkChangedAt,
625
+ probeResultTtlMs: this.lastProbeResultTtlMs ?? undefined,
626
+ probeFailureTtlMs: this.lastProbeFailureTtlMs ?? undefined,
627
+ }
628
+ ),
629
+ lastProbe: this.lastProbe,
630
+ lastProbeFailure: this.lastProbeFailure,
631
+ };
632
+ }
633
+
634
+ private acceptState(state: NetworkQualityState): NetworkQualityState {
635
+ if (
636
+ this.cachedState !== null &&
637
+ stateEqualsIgnoringSnapshotTimestamp(this.cachedState, state)
638
+ ) {
639
+ return this.cachedState;
640
+ }
641
+
642
+ this.cachedState = state;
643
+ this.stateVersion += 1;
644
+ for (const record of [...this.listeners]) {
645
+ if (record.active) record.listener(state);
646
+ }
647
+ return state;
648
+ }
649
+
650
+ private reclassifyCachedSnapshot(): void {
651
+ if (this.latestSnapshot !== null) {
652
+ this.acceptState(this.buildState(this.latestSnapshot));
653
+ }
654
+ }
655
+
656
+ private scheduleProbeExpiry(): void {
657
+ this.clearProbeExpiry();
658
+ const snapshot = this.latestSnapshot;
659
+ if (this.listeners.size === 0 || snapshot === null) return;
660
+
661
+ const expirations: number[] = [];
662
+ if (
663
+ this.lastProbe !== null &&
664
+ this.lastProbeResultTtlMs !== null &&
665
+ this.lastProbe.transport === snapshot.transport
666
+ ) {
667
+ expirations.push(
668
+ this.lastProbe.timestamp + this.lastProbeResultTtlMs + 1
669
+ );
670
+ }
671
+ if (
672
+ this.lastProbeFailure !== null &&
673
+ this.lastProbeFailureTtlMs !== null &&
674
+ this.lastProbeFailure.transport === snapshot.transport
675
+ ) {
676
+ expirations.push(
677
+ this.lastProbeFailure.timestamp + this.lastProbeFailureTtlMs + 1
678
+ );
679
+ }
680
+ const now = Date.now();
681
+ const futureExpirations = expirations.filter(
682
+ (expiration) => expiration > now
683
+ );
684
+ if (futureExpirations.length === 0) return;
685
+ const remainingMs = Math.min(...futureExpirations) - now;
686
+ const delayMs = Math.min(remainingMs, MAX_TIMER_DELAY_MS);
687
+ this.probeExpiry = setTimeout(() => {
688
+ this.probeExpiry = null;
689
+ this.reclassifyCachedSnapshot();
690
+ this.scheduleProbeExpiry();
691
+ }, delayMs);
692
+ }
693
+
694
+ private clearProbeExpiry(): void {
695
+ if (this.probeExpiry !== null) {
696
+ clearTimeout(this.probeExpiry);
697
+ this.probeExpiry = null;
698
+ }
699
+ }
700
+
701
+ private scheduleValidationGraceExpiry(): void {
702
+ this.clearValidationGraceExpiry();
703
+ const snapshot = this.latestSnapshot;
704
+ const networkChangedAt = this.networkChangedAt;
705
+ if (
706
+ snapshot === null ||
707
+ networkChangedAt === null ||
708
+ !snapshot.isConnected ||
709
+ snapshot.isValidated !== false ||
710
+ snapshot.isCaptivePortal === true
711
+ ) {
712
+ return;
713
+ }
714
+
715
+ const remainingMs =
716
+ networkChangedAt + this.config.validationGraceMs - Date.now();
717
+ if (remainingMs <= 0) return;
718
+ this.validationGraceExpiry = setTimeout(
719
+ () => {
720
+ this.validationGraceExpiry = null;
721
+ this.reclassifyCachedSnapshot();
722
+ },
723
+ Math.min(remainingMs, MAX_TIMER_DELAY_MS)
724
+ );
725
+ }
726
+
727
+ private clearValidationGraceExpiry(): void {
728
+ if (this.validationGraceExpiry !== null) {
729
+ clearTimeout(this.validationGraceExpiry);
730
+ this.validationGraceExpiry = null;
731
+ }
732
+ }
733
+
734
+ private recordProbeFailure(
735
+ error: NetworkQualityError,
736
+ transport: ProbeFailure['transport'],
737
+ resultTtlMs: number
738
+ ): void {
739
+ if (error.code !== 'E_PROBE_FAILED' && error.code !== 'E_PROBE_TIMEOUT') {
740
+ return;
741
+ }
742
+ this.lastProbeFailure = {
743
+ code: error.code,
744
+ message: error.message,
745
+ transport,
746
+ timestamp: Date.now(),
747
+ };
748
+ this.lastProbeFailureTtlMs = resultTtlMs;
749
+ this.reclassifyCachedSnapshot();
750
+ this.scheduleProbeExpiry();
751
+ }
752
+
753
+ private refreshAutoProbeLifecycle(): void {
754
+ this.stopAutoProbeLifecycle();
755
+ if (!this.config.autoProbe.enabled || this.listeners.size === 0) return;
756
+
757
+ this.appStateStatus = this.appStateApi.currentState;
758
+ this.appStateSubscription = this.appStateApi.addEventListener(
759
+ 'change',
760
+ (state) => {
761
+ this.appStateStatus = state;
762
+ this.clearAutoProbeInterval();
763
+ if (state !== 'active') {
764
+ this.clearTransportDebounce();
765
+ return;
766
+ }
767
+ this.startAutoProbeInterval();
768
+ }
769
+ );
770
+ if (this.appStateStatus === 'active') this.startAutoProbeInterval();
771
+ }
772
+
773
+ private stopAutoProbeLifecycle(): void {
774
+ this.clearAutoProbeInterval();
775
+ this.clearTransportDebounce();
776
+ this.appStateSubscription?.remove();
777
+ this.appStateSubscription = null;
778
+ }
779
+
780
+ private startAutoProbeInterval(): void {
781
+ if (this.autoProbeInterval !== null) return;
782
+ this.autoProbeInterval = setInterval(() => {
783
+ this.runAutoProbe();
784
+ }, this.config.autoProbe.intervalMs);
785
+ }
786
+
787
+ private clearAutoProbeInterval(): void {
788
+ if (this.autoProbeInterval !== null) {
789
+ clearInterval(this.autoProbeInterval);
790
+ this.autoProbeInterval = null;
791
+ }
792
+ }
793
+
794
+ private clearTransportDebounce(): void {
795
+ if (this.transportDebounce !== null) {
796
+ clearTimeout(this.transportDebounce);
797
+ this.transportDebounce = null;
798
+ }
799
+ }
800
+
801
+ private scheduleTransportChangeProbe(): void {
802
+ if (
803
+ !this.config.autoProbe.enabled ||
804
+ !this.config.autoProbe.onTransportChange ||
805
+ this.listeners.size === 0 ||
806
+ this.appStateStatus !== 'active'
807
+ ) {
808
+ return;
809
+ }
810
+
811
+ this.clearTransportDebounce();
812
+ this.transportDebounce = setTimeout(() => {
813
+ this.transportDebounce = null;
814
+ this.runAutoProbe();
815
+ }, TRANSPORT_CHANGE_DEBOUNCE_MS);
816
+ }
817
+
818
+ private runAutoProbe(): void {
819
+ const snapshot = this.latestSnapshot;
820
+ if (
821
+ !this.config.autoProbe.enabled ||
822
+ this.listeners.size === 0 ||
823
+ this.appStateStatus !== 'active' ||
824
+ snapshot === null ||
825
+ !snapshot.isConnected ||
826
+ (snapshot.isExpensive && !this.config.autoProbe.allowOnExpensive) ||
827
+ (snapshot.isConstrained && !this.config.autoProbe.allowOnConstrained)
828
+ ) {
829
+ return;
830
+ }
831
+
832
+ try {
833
+ this.probeNetwork().catch(() => {
834
+ // Automatic probes are best-effort and intentionally silent.
835
+ });
836
+ } catch {
837
+ // Synchronous validation/native errors are also silent for auto-probes.
838
+ }
839
+ }
840
+ }
841
+
842
+ const manager = new NetworkQualityManager();
843
+
844
+ /** Internal render-time support assertion used by the React hooks. */
845
+ export function assertNetworkQualitySupported(): void {
846
+ if (!manager.isSupported()) throw unsupportedError();
847
+ }
848
+
849
+ /** Returns whether the native TurboModule is linked and available. */
850
+ export function isSupported(): boolean {
851
+ return manager.isSupported();
852
+ }
853
+
854
+ /** Deep-merges runtime options into the current configuration. */
855
+ export function configure(config: DeepPartial<NetworkQualityConfig>): void {
856
+ manager.configure(config);
857
+ }
858
+
859
+ /** Returns a defensive copy of the current configuration. */
860
+ export function getConfig(): NetworkQualityConfig {
861
+ return manager.getConfig();
862
+ }
863
+
864
+ /** Fetches the current network-quality state. */
865
+ export function getNetworkQuality(): Promise<NetworkQualityState> {
866
+ return manager.getNetworkQuality();
867
+ }
868
+
869
+ /** Subscribes to live network-quality state changes. */
870
+ export function addNetworkQualityListener(
871
+ listener: (state: NetworkQualityState) => void
872
+ ): RemovableSubscription {
873
+ return manager.addNetworkQualityListener(listener);
874
+ }
875
+
876
+ /** Runs an active latency and optional throughput probe. */
877
+ export function probeNetwork(
878
+ options?: Partial<ProbeConfig>
879
+ ): Promise<ProbeResult> {
880
+ return manager.probeNetwork(options);
881
+ }
882
+
883
+ /** Returns the most recent successful active-probe result. */
884
+ export function getLastProbeResult(): ProbeResult | null {
885
+ return manager.getLastProbeResult();
886
+ }
887
+
888
+ /** Internal stable snapshot getter used by `useSyncExternalStore`. */
889
+ export function getCachedNetworkQualityState(): NetworkQualityState | null {
890
+ return manager.getCachedState();
891
+ }