UNPKG

ng-speed-test

Version:

Angular library for testing internet connection speed

434 lines (425 loc) 18.5 kB
import * as i0 from '@angular/core'; import { InjectionToken, makeEnvironmentProviders, inject, Injectable, NgModule } from '@angular/core'; import { of, Observable, throwError, merge, fromEvent } from 'rxjs'; import { switchMap, mergeMap, map, timeout, catchError, startWith } from 'rxjs/operators'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; class SpeedTestFileModel { constructor(file) { this.path = 'https://raw.githubusercontent.com/jrquick17/ng-speed-test/02c59e4afde67c35a5ba74014b91d44b33c0b3fe/demo/src/assets/5mb.jpg'; this.shouldBustCache = true; this.size = 4952221; if (file) { if (file.path !== undefined) { this.path = file.path; } if (file.size !== undefined) { this.size = file.size; } if (file.shouldBustCache !== undefined) { this.shouldBustCache = file.shouldBustCache; } } } } class SpeedTestResultsModel { constructor(fileSize) { this.fileSize = fileSize; this.duration = 0; this.hasEnded = false; this.startTime = null; this.endTime = null; this.speedBps = 0; } get speedKbps() { return this.speedBps / 1024; } get speedMbps() { return this.speedKbps / 1024; } _update() { if (this.endTime !== null && this.startTime !== null) { const milliseconds = this.endTime - this.startTime; this.duration = milliseconds / 1000; const bitsLoaded = this.fileSize * 8; // Guard against a zero (or negative, e.g. clock anomalies) duration: dividing by it // would otherwise produce Infinity/NaN, which would incorrectly pass the // `speedBps > 0` validity filter in SpeedTestService.downloadTest(). Falling back to // 0 here keeps the result finite so it is correctly discarded like any other failure. this.speedBps = this.duration > 0 ? bitsLoaded / this.duration : 0; } } end() { if (!this.hasEnded) { this.hasEnded = true; this.endTime = performance.now(); this._update(); } } error() { if (!this.hasEnded) { this.hasEnded = true; this.endTime = null; this._update(); } } start() { this.startTime = performance.now(); } } class SpeedTestSettingsModel { constructor(settings) { this.iterations = 3; this.file = new SpeedTestFileModel(); this.retryDelay = 500; if (settings) { if (settings.iterations !== undefined) { this.iterations = settings.iterations; } if (settings.retryDelay !== undefined) { this.retryDelay = settings.retryDelay; } if (settings.file) { this.file = new SpeedTestFileModel(); if (settings.file.path !== undefined) { this.file.path = settings.file.path; } if (settings.file.size !== undefined) { this.file.size = settings.file.size; } if (settings.file.shouldBustCache !== undefined) { this.file.shouldBustCache = settings.file.shouldBustCache; } } } } } const SPEED_TEST_CONFIG = new InjectionToken('SPEED_TEST_CONFIG'); /** * Configures SpeedTestService. Optional - every field has a working default and this call is * not required to use the library. Pass connectivityCheckUrl only if you want an extra, * network-verified connectivity check in addition to navigator.onLine; ng-speed-test does not * contact any third-party host on its own unless you configure one here. */ function provideSpeedTest(config = {}) { return makeEnvironmentProviders([ { provide: SPEED_TEST_CONFIG, useValue: config } ]); } class SpeedTestService { constructor() { this.config = inject(SPEED_TEST_CONFIG, { optional: true }) ?? {}; this.DEFAULT_TIMEOUT = this.config.timeout ?? 15000; // Reduced from 30s to 15s this.OFFLINE_CHECK_TIMEOUT = this.config.connectivityCheckTimeout ?? 3000; // Quick offline check } applyCacheBuster(path) { const separator = path.includes('?') ? '&' : '?'; return `${path}${separator}cache_bust=${Date.now()}_${Math.random()}`; } /** * Quick connectivity check before running speed test. * * Only contacts a third-party host if the consumer opted in via * provideSpeedTest({ connectivityCheckUrl }) - otherwise this trusts navigator.onLine, and an * unreachable network still surfaces as a real, attributable error from the file fetch itself. */ checkConnectivity() { // First check navigator.onLine if (!navigator.onLine) { return of(false); } if (!this.config.connectivityCheckUrl) { return of(true); } // Then do a quick network request to verify actual connectivity return new Observable(observer => { const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); observer.next(false); observer.complete(); }, this.OFFLINE_CHECK_TIMEOUT); fetch(this.config.connectivityCheckUrl, { method: 'HEAD', mode: 'no-cors', signal: controller.signal, cache: 'no-cache' }) .then(() => { clearTimeout(timeoutId); observer.next(true); observer.complete(); }) .catch(() => { clearTimeout(timeoutId); observer.next(false); observer.complete(); }); return () => { clearTimeout(timeoutId); controller.abort(); }; }); } downloadTest(settings, allResults = []) { // Quick connectivity check first return this.checkConnectivity().pipe(switchMap(isConnected => { if (!isConnected) { return throwError(() => new Error('No internet connection available')); } return new Observable(observer => { const testResult = new SpeedTestResultsModel(settings.file.size); const abortController = new AbortController(); let filePath = settings.file.path; if (settings.file.shouldBustCache) { filePath = this.applyCacheBuster(filePath); } testResult.start(); // Set a more aggressive timeout for the fetch request const fetchTimeout = setTimeout(() => { abortController.abort(); testResult.error(); observer.next(testResult); observer.complete(); }, this.DEFAULT_TIMEOUT); fetch(filePath, { method: 'GET', signal: abortController.signal, cache: 'no-cache' }) .then(response => { clearTimeout(fetchTimeout); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return response.blob(); }) .then(() => { testResult.end(); observer.next(testResult); observer.complete(); }) .catch(() => { clearTimeout(fetchTimeout); // Surfaced via the existing error channel: testResult.error() marks // this iteration as failed (speedBps stays 0), so it is discarded by // the `speedBps > 0` filter below rather than logged to the console. // If every iteration fails, the mergeMap below throws a descriptive // error that propagates out through the returned Observable. testResult.error(); const delay = settings.iterations !== 1 ? settings.retryDelay : 0; setTimeout(() => { observer.next(testResult); observer.complete(); }, delay); }); // Cleanup function return () => { clearTimeout(fetchTimeout); abortController.abort(); }; }); }), mergeMap((testResult) => { allResults.push(testResult); if (settings.iterations <= 1) { // Calculate average speed from all valid results const validResults = allResults.filter(result => result.speedBps > 0); if (validResults.length === 0) { return throwError(() => new Error('All speed test iterations failed - no internet connection or server unreachable')); } const totalSpeed = validResults.reduce((sum, result) => sum + result.speedBps, 0); const averageSpeed = totalSpeed / validResults.length; return of(averageSpeed); } else { settings.iterations--; return this.downloadTest(settings, allResults); } })); } validateSettings(settings) { if (!settings.file?.path) { throw new Error('ng-speed-test: File path is required'); } if (!settings.file?.size || settings.file.size <= 0) { throw new Error('ng-speed-test: Valid file size is required'); } if (settings.iterations !== undefined && settings.iterations < 1) { throw new Error('ng-speed-test: Iterations must be at least 1'); } } /** * Get internet speed in bits per second (bps) * Fails fast if no internet connection is available */ getBps(customSettings) { return new Observable(observer => { // Check connectivity immediately if (!navigator.onLine) { observer.error(new Error('No internet connection - browser reports offline')); return; } let downloadSubscription; // Small delay to ensure proper initialization const initTimeoutId = setTimeout(() => { // Create settings with proper merging const defaultSettings = new SpeedTestSettingsModel(); if (this.config.file) { defaultSettings.file = new SpeedTestFileModel(this.config.file); } const settings = this.mergeSettings(defaultSettings, customSettings); try { this.validateSettings(settings); downloadSubscription = this.downloadTest(settings).subscribe({ next: (speedBps) => { observer.next(speedBps); observer.complete(); }, error: (error) => { observer.error(error); } }); } catch (error) { observer.error(error); } }, 1); // Teardown: runs on unsubscribe (including via takeUntil/timeout upstream, or normal // completion/error). Clears the pending init setTimeout so it can't fire after // teardown, and unsubscribes the inner downloadTest() subscription, which propagates // down through its switchMap/mergeMap chain to the per-iteration fetch Observable and // triggers its own teardown (clearTimeout(fetchTimeout) + abortController.abort()), // actually cancelling the in-flight fetch instead of letting it run to completion. return () => { clearTimeout(initTimeoutId); downloadSubscription?.unsubscribe(); }; }); } /** * Properly merge custom settings with defaults */ mergeSettings(defaultSettings, customSettings) { if (!customSettings) { return defaultSettings; } const mergedSettings = new SpeedTestSettingsModel(); // Merge iterations mergedSettings.iterations = customSettings.iterations !== undefined ? customSettings.iterations : defaultSettings.iterations; // Merge retryDelay mergedSettings.retryDelay = customSettings.retryDelay !== undefined ? customSettings.retryDelay : defaultSettings.retryDelay; // Merge file settings if (customSettings.file) { mergedSettings.file = new SpeedTestFileModel(); // Merge file path mergedSettings.file.path = customSettings.file.path !== undefined ? customSettings.file.path : defaultSettings.file.path; // Merge file size mergedSettings.file.size = customSettings.file.size !== undefined ? customSettings.file.size : defaultSettings.file.size; // Merge shouldBustCache mergedSettings.file.shouldBustCache = customSettings.file.shouldBustCache !== undefined ? customSettings.file.shouldBustCache : defaultSettings.file.shouldBustCache; } else { mergedSettings.file = defaultSettings.file; } return mergedSettings; } /** * Get internet speed in kilobits per second (Kbps) */ getKbps(settings) { return this.getBps(settings).pipe(map(bps => bps / 1024)); } /** * Get internet speed in megabits per second (Mbps) */ getMbps(settings) { return this.getKbps(settings).pipe(map(kbps => kbps / 1024)); } /** * Get comprehensive speed test results with fast failure for offline scenarios */ getSpeedTestResult(settings) { const startTime = Date.now(); return this.getBps(settings).pipe(map(bps => ({ bps, kbps: bps / 1024, mbps: bps / (1024 * 1024), duration: (Date.now() - startTime) / 1000 })), timeout(this.DEFAULT_TIMEOUT + 5000), // Overall timeout slightly longer than individual request timeout catchError(error => { if (error.name === 'TimeoutError') { return throwError(() => new Error('Speed test timed out - please check your internet connection')); } return throwError(() => error); })); } /** * Check if the browser is online with enhanced detection */ isOnline() { return merge(fromEvent(window, 'offline').pipe(map(() => false)), fromEvent(window, 'online').pipe(map(() => true)), of(navigator.onLine)).pipe(startWith(navigator.onLine), // Verify actual connectivity for online state switchMap(browserOnline => { if (!browserOnline) { return of(false); } // Quick connectivity verification return this.checkConnectivity(); })); } /** * Monitor network connection status with enhanced detection */ getNetworkStatus() { const getConnectionInfo = () => { const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; return { isOnline: navigator.onLine, effectiveType: connection?.effectiveType, downlink: connection?.downlink }; }; return merge(fromEvent(window, 'offline').pipe(map(() => ({ ...getConnectionInfo(), isOnline: false }))), fromEvent(window, 'online').pipe(map(() => getConnectionInfo()), // Verify actual connectivity when browser reports online switchMap(info => this.checkConnectivity().pipe(map(actuallyOnline => ({ ...info, isOnline: actuallyOnline }))))), of(getConnectionInfo()).pipe(switchMap(info => info.isOnline ? this.checkConnectivity().pipe(map(actuallyOnline => ({ ...info, isOnline: actuallyOnline }))) : of(info)))); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.27", ngImport: i0, type: SpeedTestService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.27", ngImport: i0, type: SpeedTestService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.27", ngImport: i0, type: SpeedTestService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); class SpeedTestModule { static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.27", ngImport: i0, type: SpeedTestModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.27", ngImport: i0, type: SpeedTestModule, imports: [CommonModule, FormsModule] }); } static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.27", ngImport: i0, type: SpeedTestModule, providers: [SpeedTestService], imports: [CommonModule, FormsModule] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.27", ngImport: i0, type: SpeedTestModule, decorators: [{ type: NgModule, args: [{ imports: [CommonModule, FormsModule], providers: [SpeedTestService] }] }] }); /** * Generated bundle index. Do not edit. */ export { SPEED_TEST_CONFIG, SpeedTestFileModel, SpeedTestModule, SpeedTestResultsModel, SpeedTestService, SpeedTestSettingsModel, provideSpeedTest }; //# sourceMappingURL=ng-speed-test.mjs.map