diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1432642..28ed6a8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,3 @@ -name: Build and Release - permissions: contents: write packages: write @@ -16,22 +14,35 @@ on: jobs: build: - runs-on: ubuntu-latest + strategy: + matrix: + include: + - version: "linux-x64" + os: ubuntu-latest + platform: linux-x64 + - version: "linux-arm64" + os: ubuntu-latest + platform: linux-arm64 + - version: "windows-x64" + os: windows-latest + platform: win32-x64 + - version: "windows-arm64" + os: windows-latest + platform: win32-arm64 + - version: "darwin-x64" + os: ubuntu-latest + platform: darwin-x64 + - version: "darwin-arm64" + os: ubuntu-latest + platform: darwin-arm64 + + runs-on: ${{ matrix.os }} outputs: version: ${{ steps.get_version.outputs.VERSION }} platform: ${{ steps.detect_platform.outputs.PLATFORM }} binary_name: ${{ steps.setup_env.outputs.BINARY_NAME }} archive_name: ${{ steps.setup_env.outputs.ARCHIVE_NAME }} - strategy: - matrix: - version: - [ - "linux-x64", - "linux-arm64", - "windows-x64", - "darwin-x64", - "darwin-arm64", - ] + steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2.0.1 @@ -40,6 +51,7 @@ jobs: - name: Detect Platform and Compress Format id: detect_platform + shell: bash run: | PLATFORM="${{ matrix.version }}" echo "PLATFORM=${PLATFORM}" >> $GITHUB_OUTPUT @@ -52,12 +64,14 @@ jobs: - name: Extract version number id: get_version + shell: bash run: | VERSION=$(jq -r .version < package.json) echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT - name: Setup Build Environment id: setup_env + shell: bash run: | mkdir -p ${{ runner.temp }}/build/out mkdir -p ${{ runner.temp }}/build/logs @@ -74,40 +88,54 @@ jobs: bun install --no-install-postinstall || { echo "Failed to install dependencies"; exit 1; } - name: Compile Code + shell: bash run: | bun build --compile --sourcemap --minify --bytecode \ - --target=bun-${{ steps.detect_platform.outputs.PLATFORM }} \ + --target=bun-${{ matrix.platform }} \ ./src/cli.ts \ --outfile ${{ runner.temp }}/build/out/${{ steps.setup_env.outputs.BINARY_NAME }} - - name: Generate Checksums + - name: Generate Checksums (Linux/macOS) + if: runner.os != 'Windows' working-directory: ${{ runner.temp }}/build/out + shell: bash run: | sha1sum ${{ steps.setup_env.outputs.BINARY_NAME }}* > checksum.txt + - name: Generate Checksums (Windows) + if: runner.os == 'Windows' + working-directory: ${{ runner.temp }}/build/out + shell: pwsh + run: | + Get-FileHash -Algorithm SHA1 ${{ steps.setup_env.outputs.BINARY_NAME }}* | ForEach-Object { "$($_.Hash.ToLower()) $($_.Path.Split('\')[-1])" } | Out-File -Encoding utf8 checksum.txt + - name: Compress artifacts (Linux) if: startsWith(steps.detect_platform.outputs.PLATFORM, 'linux') working-directory: ${{ runner.temp }}/build/out + shell: bash run: | - tar -cJf ${{ steps.setup_env.outputs.ARCHIVE_NAME }} ${{ steps.setup_env.outputs.BINARY_NAME }}* checksum.txt + tar --threads=0 -c -I 'xz -5 -T0' -f ${{ steps.setup_env.outputs.ARCHIVE_NAME }} ${{ steps.setup_env.outputs.BINARY_NAME }}* checksum.txt rm -f ${{ steps.setup_env.outputs.BINARY_NAME }} - # Keep a copy of checksum.txt for later use cp checksum.txt ${{ steps.setup_env.outputs.BINARY_NAME }}.checksum.txt - - name: Compress artifacts (macOS/Windows) - if: ${{ !startsWith(steps.detect_platform.outputs.PLATFORM, 'linux') }} + - name: Compress artifacts (macOS) + if: contains(steps.detect_platform.outputs.PLATFORM, 'darwin') working-directory: ${{ runner.temp }}/build/out + shell: bash run: | - if [[ "${{ steps.detect_platform.outputs.PLATFORM }}" == *"windows"* ]]; then - zip -j ${{ steps.setup_env.outputs.ARCHIVE_NAME }} ${{ steps.setup_env.outputs.BINARY_NAME }}*.exe checksum.txt - rm -f ${{ steps.setup_env.outputs.BINARY_NAME }}*.exe - else - zip -j ${{ steps.setup_env.outputs.ARCHIVE_NAME }} ${{ steps.setup_env.outputs.BINARY_NAME }}* checksum.txt - rm -f ${{ steps.setup_env.outputs.BINARY_NAME }} - fi - # Keep a copy of checksum.txt for later use + zip -j ${{ steps.setup_env.outputs.ARCHIVE_NAME }} ${{ steps.setup_env.outputs.BINARY_NAME }}* checksum.txt + rm -f ${{ steps.setup_env.outputs.BINARY_NAME }} cp checksum.txt ${{ steps.setup_env.outputs.BINARY_NAME }}.checksum.txt + - name: Compress artifacts (Windows) + if: runner.os == 'Windows' + working-directory: ${{ runner.temp }}/build/out + shell: pwsh + run: | + Compress-Archive -Path "${{ steps.setup_env.outputs.BINARY_NAME }}*.exe","checksum.txt" -DestinationPath "${{ steps.setup_env.outputs.ARCHIVE_NAME }}" + Remove-Item "${{ steps.setup_env.outputs.BINARY_NAME }}*.exe" + Copy-Item "checksum.txt" "${{ steps.setup_env.outputs.BINARY_NAME }}.checksum.txt" + - name: Upload build artifacts uses: actions/upload-artifact@v4 with: @@ -133,7 +161,6 @@ jobs: - name: Consolidate checksums working-directory: ${{ runner.temp }}/release/ run: | - # Combine all checksum files into one cat *.checksum.txt > checksums.txt rm *.checksum.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 50038ba..1033755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,45 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.3] - 2025-01-03 + +### Added + +* **Initial release features (from 1.0.1):** + * Core speed test functionality + * Download and upload speed tests + * Latency measurement using TCP and HTTP protocols +* **New features:** + * New Aqua Speed banner images in English and Chinese + * Additional test types support (Cloudflare, LibreSpeed, Ookla) + * Improved latency measurement with ICMP, TCP, and HTTP protocols + * Dynamic chunk size adjustment for download test + * Enhanced speed test metrics with sliding window and adaptive chunk size +* **Additional features (from 1.0.2):** + * Support for DNS resolution and IP geolocation + * Cloudflare CDN location detection + * Configurable test parameters + +### Changed + +* Refactored codebase to improve maintainability and extensibility +* Optimized download test algorithm for better accuracy (from 1.0.2) +* Improved overall user experience and UI (from 1.0.2) +* Updated dependencies to latest versions + +### Fixed + +* Resolved workflow build error +* Improved error handling and logging +* Minor bug fixes and stability improvements (from 1.0.2) + +# 1.0.2 (2024-12-27) + +- Fix workflow build error + # 1.0.1 (2024-12-17) Released the first version of Aqua Speed. @@ -6,8 +48,4 @@ Released the first version of Aqua Speed. - Support for LibreSpeed - Support customized file speeding - Support multi-threaded concurrent speed measurement -- Support real-time progress display - -# 1.0.2 (2024-12-27) - -- Fix workflow build error \ No newline at end of file +- Support real-time progress display \ No newline at end of file diff --git a/README.md b/README.md index 468162f..fd4a224 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # :ocean: Aqua Speed +
+ +Aqua Speed - Banner + +
+ A modern network speed test CLI built with Bun and TypeScript.
@@ -13,7 +19,6 @@ A modern network speed test CLI built with Bun and TypeScript.
- > English | [简体中文](README.zh.md)
diff --git a/README.zh.md b/README.zh.md index 44cdbd3..908e143 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,5 +1,11 @@ # :ocean: Aqua Speed +
+ +Aqua Speed - Banner + +
+ 一个使用 Bun 和 TypeScript 构建的现代网络测速 CLI 工具。
diff --git a/assets/aqua-speed-banner-en.png b/assets/aqua-speed-banner-en.png new file mode 100644 index 0000000..31f8629 Binary files /dev/null and b/assets/aqua-speed-banner-en.png differ diff --git a/assets/aqua-speed-banner-zh.png b/assets/aqua-speed-banner-zh.png new file mode 100644 index 0000000..a6bd18f Binary files /dev/null and b/assets/aqua-speed-banner-zh.png differ diff --git a/bun.lockb b/bun.lockb index b278535..a957893 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index a327171..e93736b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "aqua-speed", "module": "./src/cli.ts", - "version": "1.0.2", + "version": "1.0.3", "description": "A modern network speed test CLI built with Bun and TypeScript.", "type": "module", "scripts": { @@ -24,6 +24,7 @@ ], "devDependencies": { "@biomejs/biome": "1.9.4", + "@types/bogon": "^1.0.2", "@types/bun": "latest", "@types/ping": "^0.4.4", "@types/psl": "^1.1.3" @@ -33,12 +34,14 @@ }, "dependencies": { "@types/user-agents": "^1.0.4", + "bogon": "^1.1.0", "bufferutil": "^4.0.8", "chalk": "^5.4.0", "commander": "^12.1.0", "ora": "^8.1.1", "ping": "^0.4.4", "psl": "^1.15.0", + "table": "^6.9.0", "undici": "^7.2.0", "user-agents": "^1.1.396", "ws": "^8.18.0" diff --git a/src/cli.ts b/src/cli.ts index 472379a..f8c6bae 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,10 +1,11 @@ import { program } from 'commander'; -import { runSpeedTest } from './controllers/startTest'; +import { runSpeedTest } from '@/controllers/runSpeedTest'; import { description, version } from '../package.json'; -import { formatTestResults } from './utils/format'; -import type { TestConfig, TestDisplay } from './types'; -import { mergeTestConfig, prepareDisplayInfo } from './controllers/processOptions'; -import { getIpGeolocation } from './models/tools/getGeoIp'; +import { formatTestResults } from '@/utils/format'; +import type { TestConfig, TestDisplay } from '@/types'; +import { mergeTestConfig, prepareDisplayInfo } from '@/controllers/processOptions'; +import { getIpGeolocation, getIpGeoOnly } from '@/models/tools/getGeoIp'; +import { resolveDns } from '@/models/tools/dnsResolver'; import chalk from 'chalk'; import { manageDebugMode, isDebugMode } from './utils/common'; @@ -32,6 +33,20 @@ async function displayStart(display: TestDisplay, config: TestConfig): Promise { + const paths = ['/backend', '/speed', '']; + const endpoint = testType === 'download' ? 'garbage.php' : 'empty.php'; + const params = testType === 'download' ? '?ckSize=100' : ''; + const path = paths[0]; + const url = `${baseUrl}${path}/${endpoint}${params}?r=${Math.random()}`; + const referrer = `${baseUrl}/speedtest_worker.js?r=${Math.random()}`; + + return { + url, + referrer, + fallbackUrls: paths.slice(1).map(p => + `${baseUrl}${p}/${endpoint}${params}?r=${Math.random()}` + ) + }; + }, + Cloudflare: (baseUrl: string, testType: string) => ({ + url: `${baseUrl}/${testType === 'download' ? '__down?bytes=10000000' : '__up?r=0'}&measId=${Math.random() * Number(10000000000000000n)}`, + referrer: "https://speed.cloudflare.com/", + fallbackUrls: [] + }), + SingleFile: (baseUrl: string, testType: string) => ({ + url: baseUrl, + referrer: '', + fallbackUrls: [] + }) +}; \ No newline at end of file diff --git a/src/constant/fetch.ts b/src/constant/fetch.ts index 3f1d119..f5dd8d5 100644 --- a/src/constant/fetch.ts +++ b/src/constant/fetch.ts @@ -1,5 +1,5 @@ import UserAgent from 'user-agents'; -import Logger from '../utils/logger'; +import Logger from '@/utils/logger'; import type { RequestInit, Dispatcher } from 'undici'; import type { ClientOptions } from 'ws'; diff --git a/src/controllers/processOptions.ts b/src/controllers/processOptions.ts index 33a9346..371dd74 100644 --- a/src/controllers/processOptions.ts +++ b/src/controllers/processOptions.ts @@ -1,6 +1,6 @@ -import { getCloudflareColoInfo } from '../models/tools/cloudflareColo'; -import type { TestConfig, TestDisplay } from '../types'; -import { isValidUrl, getDomainName } from '../utils/common'; +import { getCloudflareColoInfo } from '@/models/tools/cloudflareColo'; +import type { TestConfig, TestDisplay } from '@/types'; +import { isValidUrl, getDomainName } from '@/utils/common'; /** * Default Test Config @@ -131,7 +131,7 @@ async function prepareDisplayInfo(config: TestConfig): Promise { */ function createDefaultDisplayInfo(config: TestConfig): TestDisplay { return { - serverName: config.server, + serverName: config.server || DEFAULT_CONFIG.server, flags: [], testInfo: { Server: config.server || DEFAULT_CONFIG.server, diff --git a/src/controllers/startTest.ts b/src/controllers/runSpeedTest.ts similarity index 94% rename from src/controllers/startTest.ts rename to src/controllers/runSpeedTest.ts index 4c40f83..af8303c 100644 --- a/src/controllers/startTest.ts +++ b/src/controllers/runSpeedTest.ts @@ -1,9 +1,9 @@ -import type { SpeedTestOptions, TestResult, SpeedStats, LatencyResult } from '../types'; -import { measureLatency } from '../models/latencyTest'; -import { sleep, usToMs, isDebugMode } from '../utils/common'; -import { measureDownload, measureUpload } from '../models/speedTest'; -import Logger from '../utils/logger'; -import { getDomainName } from '../utils/common'; +import type { SpeedTestOptions, TestResult, SpeedStats, LatencyResult } from '@/types'; +import { measureLatency } from '@/models/latencyTest'; +import { sleep, usToMs, isDebugMode } from '@/utils/common'; +import { measureDownload, measureUpload } from '@/models'; +import Logger from '@/utils/logger'; +import { getDomainName } from '@/utils/common'; const logger = new Logger(); diff --git a/src/index.ts b/src/index.ts index 856e111..90e49d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ -import * as speedtest from './controllers/startTest'; +import * as speedtest from '@/controllers/runSpeedTest'; -export * from './controllers/startTest'; -export type * from './types'; +export * from '@/controllers/runSpeedTest'; +export type * from '@/types'; export default speedtest; \ No newline at end of file diff --git a/src/models/algorithms/fallback.ts b/src/models/algorithms/fallback.ts new file mode 100644 index 0000000..0d45cf5 --- /dev/null +++ b/src/models/algorithms/fallback.ts @@ -0,0 +1,27 @@ +import type { TestConfigBase, TestType } from '@/types'; +import { SpeedTestError } from '@/models/index'; + +/** + * Attempts to perform a speed test with a given URL + * @param {string} url - The URL to test + * @param {string} referrer - The referrer URL + * @param {Function} workerFn - Worker function that performs the actual speed test + * @param {TestConfigBase} config - Test configuration + * @param {AbortSignal} signal - AbortSignal for cancelling the test + * @param {TestType} testType - Type of the test + * @returns {Promise} Promise resolving to the speed measurement + */ +export async function attemptSpeedTest( + url: string, + referrer: string, + workerFn: (url: string, referrer: string, onProgress: (speed: number) => void, signal: AbortSignal, testType: TestType) => Promise, + config: TestConfigBase, + signal: AbortSignal, + onProgress: (speed: number) => void +): Promise { + try { + return await workerFn(url, referrer, onProgress, signal, config.type || 'SingleFile'); + } catch (error) { + throw new SpeedTestError(`Speed test failed for URL ${url}`, error as Error); + } +} \ No newline at end of file diff --git a/src/models/algorithms/thread.ts b/src/models/algorithms/thread.ts new file mode 100644 index 0000000..8b46c70 --- /dev/null +++ b/src/models/algorithms/thread.ts @@ -0,0 +1,180 @@ +import type { TestConfigBase, SpeedStats } from '@/types'; +import { DEFAULT_TEST_OPTIONS } from '@/constant/default'; + +/** + * Internal types for network metrics and adaptive thresholds. + */ +interface NetworkMetrics { + stability: number; + congestion: number; + trend: number; + variance: number; +} + +interface AdaptiveThresholds { + errorTolerance: number; + stabilityThreshold: number; + congestionThreshold: number; +} + +const DEFAULT_ADAPTIVE_THRESHOLDS: AdaptiveThresholds = { + errorTolerance: 0.15, + stabilityThreshold: 0.5, + congestionThreshold: 0.7, +}; + +/** + * Dynamically adjusts the number of active threads based on network performance metrics. + * Enhanced to consider multiple factors for more intelligent adjustments. + * @param samples - Array of speed samples from previous tests + * @param activeThreads - Current number of active threads + * @param stats - Current test statistics + * @param config - Test configuration + * @returns Optimized number of threads + */ +export function adjustThreadCount( + samples: number[], + activeThreads: number, + stats: SpeedStats, + config: TestConfigBase +): number { + const { targetError = DEFAULT_ADAPTIVE_THRESHOLDS.errorTolerance, minTestTime, maxTestTime } = { + ...DEFAULT_TEST_OPTIONS, + ...config, + }; + + const maxThreads = Math.min(12, (config.thread || DEFAULT_TEST_OPTIONS.thread) * 2); + const minThreads = Math.max(1, Math.floor((config.thread || DEFAULT_TEST_OPTIONS.thread) / 4)); + + if (samples.length < 3) return activeThreads; + + const metrics = calculateNetworkMetrics(samples, stats); + + // Aggressive scaling: Increase threads if error is significantly higher and network is stable + if (stats.error > targetError * 2 && metrics.stability > 0.6) { + return Math.min(activeThreads + 2, maxThreads); + } + + // Conservative scaling: Decrease threads if error is significantly lower and network is very stable + if (stats.error < targetError * 0.5 && metrics.stability > 0.8) { + return Math.max(activeThreads - 1, minThreads); + } + + // Dynamic adjustment based on comprehensive network metrics + if (shouldAdjustThreads(metrics, stats, targetError)) { + const adjustment = calculateThreadAdjustment(metrics, stats, targetError); + const newThreads = activeThreads + adjustment; + return Math.max(minThreads, Math.min(maxThreads, newThreads)); + } + + return activeThreads; +} + +/** + * Calculates comprehensive network metrics based on speed samples and current statistics. + * @param samples - Array of recent speed samples + * @param stats - Current test statistics + * @returns NetworkMetrics object containing stability, congestion, trend, and variance + */ +function calculateNetworkMetrics(samples: number[], stats: SpeedStats): NetworkMetrics { + const recentSamples = samples.slice(-5); + const trend = calculateTrend(recentSamples); + const variance = calculateVariance(recentSamples); + const stability = calculateStability(variance, stats); + const congestion = calculateCongestion(trend, stats); + + return { stability, congestion, trend, variance }; +} + +/** + * Calculates the trend of speed samples. + * Positive trend indicates increasing speed, negative indicates decreasing. + * @param samples - Array of speed samples + * @returns Trend value + */ +function calculateTrend(samples: number[]): number { + if (samples.length < 2) return 0; + const deltas = samples.slice(1).map((speed, index) => (speed - samples[index]) / samples[index]); + return deltas.reduce((a, b) => a + b, 0) / deltas.length; +} + +/** + * Calculates the variance of speed samples. + * @param samples - Array of speed samples + * @returns Variance value + */ +function calculateVariance(samples: number[]): number { + const mean = samples.reduce((a, b) => a + b, 0) / samples.length; + const squaredDiffs = samples.map(x => (x - mean) ** 2); + return squaredDiffs.reduce((a, b) => a + b, 0) / samples.length; +} + +/** + * Calculates the stability index of the network. + * Combines variance and error factors to determine stability. + * @param variance - Variance of speed samples + * @param stats - Current test statistics + * @returns Stability index between 0 and 1 + */ +function calculateStability(variance: number, stats: SpeedStats): number { + const varFactor = Math.max(0, 1 - variance / stats.avg); + const errorFactor = Math.max(0, 1 - stats.error); + return (varFactor + errorFactor) / 2; +} + +/** + * Calculates the congestion level of the network. + * Considers trend and error to determine congestion. + * @param trend - Trend of speed samples + * @param stats - Current test statistics + * @returns Congestion level between 0 and 1 + */ +function calculateCongestion(trend: number, stats: SpeedStats): number { + const trendFactor = trend < 0 ? Math.min(1, -trend / 0.5) : 0; + const errorFactor = Math.min(1, stats.error * 2); + return (trendFactor + errorFactor) / 2; +} + +/** + * Determines if the thread count should be adjusted based on network metrics. + * @param metrics - Network metrics + * @param stats - Current test statistics + * @param targetError - Target error rate + * @returns Boolean indicating whether to adjust threads + */ +function shouldAdjustThreads( + metrics: NetworkMetrics, + stats: SpeedStats, + targetError: number +): boolean { + return ( + Math.abs(stats.error - targetError) > targetError * 0.3 || + Math.abs(metrics.trend) > 0.15 || + metrics.stability < 0.5 || + metrics.congestion > 0.7 + ); +} + +/** + * Calculates the adjustment value for thread count based on network metrics. + * @param metrics - Network metrics + * @param stats - Current test statistics + * @param targetError - Target error rate + * @returns Adjustment value (positive to increase, negative to decrease) + */ +function calculateThreadAdjustment( + metrics: NetworkMetrics, + stats: SpeedStats, + targetError: number +): number { + const errorDiff = stats.error - targetError; + let adjustment = Math.sign(errorDiff) * (Math.abs(errorDiff) > targetError * 0.5 ? 2 : 1); + + // Further refine adjustment based on specific network conditions + if (metrics.congestion > 0.8) adjustment -= 1; + if (metrics.stability < 0.3) adjustment -= 1; + if (metrics.trend < -0.2) adjustment -= 1; + if (metrics.trend > 0.2 && metrics.stability > 0.7) adjustment += 1; + + return adjustment; +} diff --git a/src/models/speedTest.ts b/src/models/index.ts similarity index 72% rename from src/models/speedTest.ts rename to src/models/index.ts index a171c8b..84b5226 100644 --- a/src/models/speedTest.ts +++ b/src/models/index.ts @@ -1,22 +1,13 @@ -import type { TestConfigBase, DownloadTestConfig, SpeedStats, UploadTestConfig, TestType } from '../types'; -import { sleep, calculateStats, isDebugMode } from '../utils/common'; -import { formatSpeed } from '../utils/format'; -import Logger from '../utils/logger'; -import { downloadTestWorker } from './workers/download'; -import { testUploadWorker } from './workers/upload'; -import { checkUrlAvailability } from './workers/check'; - -// Constants -const DEFAULT_CONFIG = { - minTestTime: 5000, - maxTestTime: 30000, - targetError: 0.05, - minSamples: 3, - progressInterval: 200, - thread: 4, - type: 'SingleFile' as TestType, - debug: isDebugMode() -}; +import type { TestConfigBase, DownloadTestConfig, SpeedStats, UploadTestConfig, TestType } from '@/types'; +import { DEFAULT_TEST_OPTIONS } from '@/constant/default'; +import { sleep, calculateStats } from '@/utils/common'; +import { formatSpeed } from '@/utils/format'; +import Logger from '@/utils/logger'; +import { downloadTestWorker } from '@/models/workers/download'; +import { testUploadWorker } from '@/models/workers/upload'; +import { checkUrlAvailability } from '@/models/workers/check'; +import { adjustThreadCount } from '@/models/algorithms/thread'; +import { attemptSpeedTest } from '@/models/algorithms/fallback'; const logger = new Logger(); @@ -91,59 +82,6 @@ function getTestEndpoint(testEndpoint: string, config: TestConfigBase, testType: } } -/** - * Attempts to perform a speed test with a given URL - * @param {string} url - The URL to test - * @param {string} referrer - The referrer URL - * @param {Function} workerFn - Worker function that performs the actual speed test - * @param {TestConfigBase} config - Test configuration - * @param {AbortSignal} signal - AbortSignal for cancelling the test - * @param {TestType} testType - Type of the test - * @returns {Promise} Promise resolving to the speed measurement - */ -async function attemptSpeedTest( - url: string, - referrer: string, - workerFn: (url: string, referrer: string, onProgress: (speed: number) => void, signal: AbortSignal, testType: TestType) => Promise, - config: TestConfigBase, - signal: AbortSignal, - onProgress: (speed: number) => void -): Promise { - try { - return await workerFn(url, referrer, onProgress, signal, config.type || 'SingleFile'); - } catch (error) { - throw new SpeedTestError(`Speed test failed for URL ${url}`, error as Error); - } -} - -/** - * Adjusts the number of active threads based on test performance - * @param {number[]} samples - Array of speed samples from previous tests - * @param {number} activeThreads - Current number of active threads - * @param {SpeedStats} stats - Current test statistics - * @param {TestConfigBase} config - Test configuration - * @returns {number} The adjusted number of threads - */ -function adjustThreadCount(samples: number[], activeThreads: number, stats: SpeedStats, config: TestConfigBase): number { - const { targetError } = { ...DEFAULT_CONFIG, ...config }; - const maxThreads = Math.min(8, (config.thread || DEFAULT_CONFIG.thread) * 2); - const minThreads = Math.max(1, Math.floor((config.thread || DEFAULT_CONFIG.thread) / 2)); - - if (samples.length < 2) return activeThreads; - - const lastSpeed = samples[samples.length - 1]; - const prevSpeed = samples[samples.length - 2]; - const speedDiff = Math.abs(lastSpeed - prevSpeed) / prevSpeed; - - if (speedDiff > 0.2 && stats.error > targetError * 1.5) { - return Math.min(activeThreads + 1, maxThreads); - } - if (speedDiff < 0.1 && stats.error < targetError / 2) { - return Math.max(activeThreads - 1, minThreads); - } - return activeThreads; -} - /** * Measures speed (download or upload) using multiple threads * @param {string} testEndpoint - The endpoint URL for the speed test @@ -164,7 +102,8 @@ async function measureSpeed( throw new SpeedTestError('Upload test is not supported for SingleFile type'); } - const mergedConfig = { ...DEFAULT_CONFIG, ...config }; + // Merge the default test options with the user-defined options + const mergedConfig = { ...DEFAULT_TEST_OPTIONS, ...config }; const { minTestTime, maxTestTime, @@ -184,9 +123,9 @@ async function measureSpeed( let urlIndex = 0; if (debug) { - logger.debug(`[measureSpeed] initialUrl: ${initialUrl}, referrer: ${referrer}, testType: ${testType}, type: ${config.type}`); + logger.info(`[measureSpeed] initialUrl: ${initialUrl}, referrer: ${referrer}, testType: ${testType}, type: ${config.type}`); if (fallbackUrls.length > 0) { - logger.debug(`[measureSpeed] fallbackUrls: ${fallbackUrls.join(', ')}`); + logger.info(`[measureSpeed] fallbackUrls: ${fallbackUrls.join(', ')}`); } } @@ -196,7 +135,7 @@ async function measureSpeed( throw new SpeedTestError('All URLs are unavailable'); } if (debug) { - logger.debug(`[measureSpeed] URL ${currentUrl} is not available, trying fallback URL: ${fallbackUrls[urlIndex]}`); + logger.info(`[measureSpeed] URL ${currentUrl} is not available, trying fallback URL: ${fallbackUrls[urlIndex]}`); } currentUrl = fallbackUrls[urlIndex]; urlIndex++; @@ -311,4 +250,4 @@ async function measureUpload( } } -export { measureDownload, measureUpload }; \ No newline at end of file +export { measureDownload, measureUpload, SpeedTestError }; \ No newline at end of file diff --git a/src/models/latencyTest.ts b/src/models/latencyTest.ts index 2bc81df..6b3b3cb 100644 --- a/src/models/latencyTest.ts +++ b/src/models/latencyTest.ts @@ -6,6 +6,7 @@ import { type Dispatcher, fetch, type HeadersInit } from 'undici'; import WebSocket from 'ws'; import { DEFAULT_FETCH_OPTIONS, DEFAULT_FETCH_HEADERS_OOKLA } from '../constant/fetch'; import { isDebugMode } from '../utils/common'; +import net from 'node:net'; const logger = new Logger(); @@ -26,7 +27,7 @@ async function measureICMPLatency(host: string, count = 3): Promise { let totalLatency = 0; let successCount = 0; - for (let i = 0; i < count; i++) { + const pingPromises = Array.from({ length: count }, async () => { try { const result = await ping.probe(hostname, { timeout: 2, @@ -37,9 +38,11 @@ async function measureICMPLatency(host: string, count = 3): Promise { successCount++; } } catch (err) { - logger.error(`[measureICMPLatency] Error: ${err}`); + logger.error(`[measureICMPLatency] Ping error: ${err}`); } - } + }); + + await Promise.all(pingPromises); return successCount === 0 ? -1 : Math.round(totalLatency / successCount); } catch (error) { @@ -49,10 +52,11 @@ async function measureICMPLatency(host: string, count = 3): Promise { } /** - * Measures TCP connection latency for a given host - * @param {string} host - The target host URL - * @param {number} [samples=3] - Number of connection attempts - * @returns {Promise} Average latency in microseconds, or -1 if measurement fails + * Measures TCP connection latency for a given host. + * Utilizes Node.js's native 'net' module for TCP connections. + * @param {string} host - The target host URL. + * @param {number} [samples=3] - Number of connection attempts. + * @returns {Promise} Average latency in microseconds, or -1 if measurement fails. */ async function measureTCPLatency(host: string, samples = 3): Promise { if (!host) { @@ -70,71 +74,76 @@ async function measureTCPLatency(host: string, samples = 3): Promise { const delays: number[] = []; - try { - for (let i = 0; i < samples; i++) { - const start = performance.now(); - - const socket = await Bun.connect({ - socket: { - open() { }, - close() { }, - data() { }, - error(error) { - logger.error(`[measureTCPLatency] TCP connection error: ${error}`); - }, - }, - hostname: url.hostname, - port: Number(url.port) || 80, - allowHalfOpen: false + const tcpMeasurementPromises = Array.from({ length: samples }, async () => { + return new Promise((resolve) => { + const start = process.hrtime.bigint(); + const socket = new net.Socket(); + + const timeout = setTimeout(() => { + socket.destroy(); + logger.warn(`[measureTCPLatency] TCP connection to ${url.hostname}:${url.port || 80} timed out`); + resolve(-1); + }, 2000); // 2 seconds timeout + + socket.connect(Number(url.port || 80), url.hostname, () => { + clearTimeout(timeout); + const end = process.hrtime.bigint(); + const latency = Number(end - start) / 1000; // Convert nanoseconds to microseconds + socket.end(); + resolve(latency); }); - socket.end(); - - const latency = performance.now() - start; - delays.push(latency); + socket.on('error', (err) => { + clearTimeout(timeout); + logger.error(`[measureTCPLatency] TCP connection error: ${err.message}`); + resolve(-1); + }); + }); + }); - await sleep(100); - } + const results = await Promise.all(tcpMeasurementPromises); + for (const latency of results) { + if (latency !== -1) delays.push(latency); + } - const avgDelay = delays.reduce((a, b) => a + b, 0) / delays.length; - return Math.round(avgDelay * 1000); + if (delays.length === 0) return -1; - } catch (error) { - if (isDebugMode()) { - logger.error(`[measureTCPLatency] Error: ${error}`); - } - return -1; - } + const avgDelay = delays.reduce((a, b) => a + b, 0) / delays.length; + return Math.round(avgDelay); } /** - * Measures HTTP/2 latency for a given URL - * @param {string} url - The target URL - * @param {RequestInit} options - Fetch request options - * @returns {Promise} Latency in microseconds, or special error codes - * - Returns -1 for general errors - * - Returns -2 to signal HTTP/1.1 fallback + * Measures HTTP/2 latency for a given URL. + * @param {string} url - The target URL. + * @param {RequestInit} options - Fetch request options. + * @returns {Promise} Latency in microseconds, or special error codes. + * - Returns -1 for general errors. + * - Returns -2 to signal HTTP/1.1 fallback. */ -async function measureHTTPLatencyH2(url: string, options: RequestInit): Promise { +async function measureHTTPLatencyH2(url: string, options: Dispatcher.RequestOptions): Promise { const controller = new AbortController(); - const start = performance.now(); - + const start = process.hrtime.bigint(); + try { const response = await fetch(url, { ...options, signal: controller.signal, - // @ts-ignore, this is a features of undici, not a bug + // @ts-ignore: undici supports 'httpVersion' httpVersion: '2.0' }); controller.abort(); - return response.ok ? msToMicros(performance.now() - start) : -1; - } catch (error) { + const end = process.hrtime.bigint(); + const latency = Number(end - start) / 1000; // Convert to ms + return response.ok ? Math.round(latency) : -1; + } catch (error: unknown) { if (error instanceof Error && error.name === 'UnsupportedProtocolError') { - return -2; // Signal to fallback to HTTP/1.1 + return -2; // Try fallback to HTTP/1.1 } if (error instanceof Error && error.name === 'AbortError') { - return msToMicros(performance.now() - start); + const end = process.hrtime.bigint(); + const latency = Number(end - start) / 1000; + return Math.round(latency); } if (isDebugMode()) { logger.error(`[measureHTTPLatencyH2] Error: ${error}`); @@ -148,37 +157,39 @@ async function measureHTTPLatencyH2(url: string, options: RequestInit): Promise< * @param {URL} url - The target URL * @returns {Promise} Latency in microseconds, or -1 if connection fails */ -async function measureHTTPLatencyWS(url: URL): Promise { +async function measureWebSocketLatency(url: URL): Promise { return new Promise((resolve) => { - const start = performance.now(); - const ws = new WebSocket(url.href.replace('https', 'wss'), { + const start = process.hrtime.bigint(); + const ws = new WebSocket(url.href.replace('http', 'ws'), { headers: DEFAULT_FETCH_HEADERS_OOKLA }); const timeout = setTimeout(() => { - ws.close(); + ws.terminate(); resolve(-1); - }, 5000); + }, 5000); // 5 seconds timeout ws.on('open', () => { clearTimeout(timeout); + const end = process.hrtime.bigint(); + const latency = Number(end - start) / 1000; // Convert to ms ws.close(); - resolve(msToMicros(performance.now() - start)); + resolve(Math.round(latency)); }); ws.on('error', () => { clearTimeout(timeout); - ws.close(); + ws.terminate(); resolve(-1); }); }); } /** - * Measures HTTP latency for a given URL with different test types - * @param {URL} url - The target URL object - * @param {TestType} type - The type of speed test (Cloudflare, LibreSpeed, Ookla) - * @returns {Promise} Latency in microseconds, or -1 if measurement fails + * Measures HTTP latency for a given URL with different test types. + * @param {URL} url - The target URL object. + * @param {TestType} type - The type of speed test (Cloudflare, LibreSpeed, Ookla). + * @returns {Promise} Latency in microseconds, or -1 if measurement fails. */ async function measureHTTPLatency(url: URL, type: TestType): Promise { if (!url || !(url instanceof URL)) { @@ -186,68 +197,74 @@ async function measureHTTPLatency(url: URL, type: TestType): Promise { return -1; } - const commonOptions: RequestInit = { + const commonOptions: Dispatcher.RequestOptions = { method: 'GET', - cache: 'no-store', + headers: { + ...DEFAULT_FETCH_OPTIONS.headers, + 'Referer': url.origin, + 'Origin': url.origin, + }, + path: url.pathname, }; let testUrl: string; - let options: RequestInit & Dispatcher.RequestOptions; + let options: Dispatcher.RequestOptions; switch (type) { case 'Cloudflare': - testUrl = `${url.origin}`; + testUrl = `${url.origin}/cdn-cgi/trace`; options = { ...commonOptions, - headers: { - ...DEFAULT_FETCH_OPTIONS.headers, - 'Referer': url.origin, - 'Origin': url.origin, - 'Path': '/cdn-cgi/trace', - } - } as unknown as RequestInit & Dispatcher.RequestOptions; + path: '/cdn-cgi/trace', + }; break; case 'LibreSpeed': testUrl = url.href; options = { ...commonOptions, - ...DEFAULT_FETCH_OPTIONS, - path: new URL(url.href).pathname, - } as RequestInit & Dispatcher.RequestOptions; + path: url.pathname, + }; break; case 'Ookla': - return measureHTTPLatencyWS(url); + return measureWebSocketLatency(url); default: testUrl = url.href; - options = commonOptions as unknown as RequestInit & Dispatcher.RequestOptions; + options = { + ...commonOptions, + path: url.pathname, + }; } - // Try HTTP/2 first + // Try HTTP/2 latency measurement const h2Result = await measureHTTPLatencyH2(testUrl, options); if (h2Result !== -2) { return h2Result; } - // Fallback to HTTP/1.1 + // If HTTP/2 is not supported, fallback to HTTP/1.1 const controller = new AbortController(); - const start = performance.now(); + const start = process.hrtime.bigint(); try { const response = await fetch(testUrl, { ...options, signal: controller.signal, - // @ts-ignore, this is a features of undici, not a bug - httpVersion: '1.1' + // @ts-ignore: undici supports 'httpVersion' + httpVersion: '1.1', }); controller.abort(); - return response.ok ? msToMicros(performance.now() - start) : -1; - } catch (err) { + const end = process.hrtime.bigint(); + const latency = Number(end - start) / 1000; // Convert to ms + return response.ok ? Math.round(latency) : -1; + } catch (err: unknown) { if (isDebugMode()) { logger.error(`[measureHTTPLatency] Error: ${err}`); } if (err instanceof Error && err.name === 'AbortError') { - return msToMicros(performance.now() - start); + const end = process.hrtime.bigint(); + const latency = Number(end - start) / 1000; + return Math.round(latency); } return -1; } @@ -255,9 +272,10 @@ async function measureHTTPLatency(url: URL, type: TestType): Promise { /** * Performs a comprehensive latency measurement for a given test endpoint + * Measures latency across ICMP, TCP, and HTTP protocols * @param {string} testEndpoint - The URL of the test endpoint * @param {TestType} type - The type of speed test (Cloudflare, LibreSpeed, Ookla) - * @returns {Promise<{icmp: LatencyStats, tcp: LatencyStats, http: LatencyStats}>} + * @returns {Promise<{icmp: LatencyStats, tcp: LatencyStats, http: LatencyStats}>} * Latency statistics for ICMP, TCP, and HTTP protocols * @throws {Error} If the test endpoint is invalid or the test fails */ @@ -285,26 +303,36 @@ export async function measureLatency(testEndpoint: string, type: TestType): Prom const latencySpinner = logger.create('latency', 'Measuring latency...'); const updateInterval = setInterval(() => { - const latencyIcmpMs = usToMs(calculateStats(icmpSamples).avg); - const latencyTcpMs = usToMs(calculateStats(tcpSamples).avg); - const latencyHttpMs = usToMs(calculateStats(httpSamples).avg); - latencySpinner.text = `Testing latency... Current: ${latencyIcmpMs}ms, TCP: ${latencyTcpMs}ms, HTTP: ${latencyHttpMs}ms`; + const icmpStats = calculateStats(icmpSamples); + const tcpStats = calculateStats(tcpSamples); + const httpStats = calculateStats(httpSamples); + + latencySpinner.text = `Testing latency... ICMP: ${usToMs(icmpStats.avg).toFixed(2)}ms, TCP: ${usToMs(tcpStats.avg).toFixed(2)}ms, HTTP: ${usToMs(httpStats.avg).toFixed(2)}ms`; }, 1000); try { - for (let i = 0; i < rounds; i++) { - if (i > 0) await sleep(500); + const measurementPromises = Array.from({ length: rounds }, async (_, i) => { + if (i > 0) await sleep(500); // Wait 500ms between rounds + + const [icmp, tcp, http] = await Promise.all([ + measureICMPLatency(host), + measureTCPLatency(host), + measureHTTPLatency(url, type) + ]); + + icmpSamples.push(icmp); + tcpSamples.push(tcp); + httpSamples.push(http); + }); - icmpSamples.push(await measureICMPLatency(host)); - tcpSamples.push(await measureTCPLatency(host)); - httpSamples.push(await measureHTTPLatency(url, type)); - } + await Promise.all(measurementPromises); } catch (error) { latencySpinner.fail('Latency test failed'); logger.error(`[measureLatency] Error: ${error}`); throw error; } finally { clearInterval(updateInterval); + latencySpinner.succeed('Latency measurement completed'); } return { @@ -312,4 +340,4 @@ export async function measureLatency(testEndpoint: string, type: TestType): Prom tcp: calculateStats(tcpSamples), http: calculateStats(httpSamples) }; -} \ No newline at end of file +} diff --git a/src/models/tools/cloudflareColo.ts b/src/models/tools/cloudflareColo.ts index 351b4f2..c9a2a68 100644 --- a/src/models/tools/cloudflareColo.ts +++ b/src/models/tools/cloudflareColo.ts @@ -1,4 +1,4 @@ -import type { ColoResult } from '../../types'; +import type { ColoResult } from '@/types'; /** * Get Cloudflare CDN Colo Info diff --git a/src/models/tools/dnsResolver.ts b/src/models/tools/dnsResolver.ts new file mode 100644 index 0000000..cdaa7bf --- /dev/null +++ b/src/models/tools/dnsResolver.ts @@ -0,0 +1,156 @@ +import dns from 'node:dns'; +import { promisify } from 'node:util'; +import Logger from '@/utils/logger'; +import psl from 'psl'; +import bogon from 'bogon'; + +const logger = new Logger(); + +const resolve4Async = promisify(dns.resolve4); +const resolve6Async = promisify(dns.resolve6); + +interface DnsResult { + ip?: string; +} + +type PrefType = 4 | 6 | null; + +interface DoHAnswer { + name: string; + type: number | string; + TTL: number; + data?: string; // Cloudfalre 1.1.1.1 & RFC 8484 + ip?: string; // Alibaba Cloud DNS +} + +interface DoHProvider { + name: string; + resolve: (url: string, type: 'A' | 'AAAA') => Promise; +} + +const isTypeMatch = (answer: DoHAnswer, type: 'A' | 'AAAA'): boolean => { + if (typeof answer.type === 'number') { + return (type === 'A' && answer.type === 1) || (type === 'AAAA' && answer.type === 28); + } + return answer.type === type; +}; + +const fetchDoH = async (url: string, type: 'A' | 'AAAA', dohUrl: string): Promise => { + try { + const response = await fetch(`${dohUrl}?name=${encodeURIComponent(url)}&type=${type}`, { + headers: { + 'accept': 'application/dns-json', + }, + }); + + if (!response.ok) { + throw new Error(`DoH request failed with status ${response.status}`); + } + + const data = await response.json(); + + if (!data.Answer) { + return []; + } + + return data.Answer + .filter((answer: DoHAnswer) => isTypeMatch(answer, type)) + .map((answer: DoHAnswer) => 'data' in answer ? answer.data : answer.ip) + .filter(Boolean); + } catch (error) { + console.warn(`DoH fetch failed: ${(error as Error).message}`); + return []; + } +}; + +const cloudflareProvider: DoHProvider = { + name: 'Cloudflare 1.1.1.1', + resolve: async (url: string, type: 'A' | 'AAAA') => { + const dohUrl = 'https://cloudflare-dns.com/dns-query'; + return fetchDoH(url, type, dohUrl); + }, +}; + +const aliProvider: DoHProvider = { + name: 'Alibaba Public DNS', + resolve: async (url: string, type: 'A' | 'AAAA') => { + const dohUrl = 'https://dns.alidns.com/resolve'; + return fetchDoH(url, type, dohUrl); + }, +}; + +const providers: DoHProvider[] = [cloudflareProvider, aliProvider]; + +const selectValidIPv4 = (ips: string[]): string | null => { + const valid = ips.filter(ip => ip && ip !== '0.0.0.0' && ip !== '127.0.0.1' && !bogon(ip)); + return valid.length > 0 ? valid[0] : null; +}; + +export async function resolveDns(url: string, pref: PrefType = null): Promise { + const realDomain = new URL(url).hostname; + const result: DnsResult = {}; + let ipv4s: string[] = []; + let ipv6s: string[] = []; + + logger.debug(`[Resolve DNS] Domain: ${realDomain}`) + + if (!psl.isValid(realDomain)) { + logger.debug(`[Resolve DNS] ${realDomain} is not a valid domain.`) + return result; + } + + try { + // Using system DNS + if (pref === 4 || pref === null) { + try { + ipv4s = await resolve4Async(realDomain); + const validIPv4 = selectValidIPv4(ipv4s); + if (validIPv4) { + result.ip = validIPv4; + return result; + } + } catch (error) { + console.warn(`IPv4 system DNS resolution failed: ${(error as Error).message}`); + } + } + + if (pref === 6 || pref === null) { + try { + ipv6s = await resolve6Async(realDomain); + if (ipv6s.length > 0) { + result.ip = ipv6s[0]; + return result; + } + } catch (error) { + console.warn(`IPv6 system DNS resolution failed: ${(error as Error).message}`); + } + } + + // Try DoH + for (const provider of providers) { + if (pref === 6 || pref === null) { + const fetchedIPv6s = await provider.resolve(realDomain, 'AAAA'); + if (fetchedIPv6s.length > 0) { + result.ip = fetchedIPv6s[0]; + return result; + } + } + + if (pref === 4 || pref === null) { + const fetchedIPv4s = await provider.resolve(realDomain, 'A'); + const validIPv4 = selectValidIPv4(fetchedIPv4s); + if (validIPv4) { + result.ip = validIPv4; + return result; + } + } + } + + // Fallback '' + return result; + + } catch (error) { + console.warn(`DNS resolution error: ${(error as Error).message}`); + return result; + } +} \ No newline at end of file diff --git a/src/models/tools/getGeoIp.ts b/src/models/tools/getGeoIp.ts index 8fea8b3..67e7769 100644 --- a/src/models/tools/getGeoIp.ts +++ b/src/models/tools/getGeoIp.ts @@ -1,7 +1,7 @@ -import type { IpGeoResponse, TestConfig } from '../../types'; -import { DEFAULT_FETCH_OPTIONS, WS_OPTIONS_OOKLA } from '../../constant/fetch'; -import { isDebugMode } from '../../utils/common'; -import Logger from '../../utils/logger'; +import type { IpGeoResponse, TestConfig } from '@/types'; +import { DEFAULT_FETCH_OPTIONS, WS_OPTIONS_OOKLA } from '@/constant/fetch'; +import { isDebugMode } from '@/utils/common'; +import Logger from '@/utils/logger'; import { type Dispatcher, fetch, Request, Response } from 'undici'; import WebSocket from 'ws'; @@ -452,3 +452,35 @@ export async function getIpGeolocation(config: TestConfig): Promise { + try { + const source = new class extends BaseIpSource { + async getIp(): Promise { + throw new Error('Method not implemented.'); + } + async getGeo(ip?: string): Promise { + return this.getIpInfoGeo(ip); + } + }; + return await source.getGeo(ip); + } catch (error) { + if (isDebugMode()) { + logger.error(error instanceof Error ? error.message : String(error), { + name: error instanceof Error ? error.name : 'Unknown Error', + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + } + + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Error getting geolocation for IP ${ip}: ${errorMessage}`); + } +} diff --git a/src/models/workers/check.ts b/src/models/workers/check.ts index 7bd5143..6d5e66a 100644 --- a/src/models/workers/check.ts +++ b/src/models/workers/check.ts @@ -40,7 +40,9 @@ export async function checkUrlAvailability(url: string, referrer: string, testTy // Abort the request immediately after getting the status code controller.abort(); - const result = !(response.status >= 400 && response.status < 500); + // const result = !(response.status >= 400 && response.status < 500); + // TODO: Fix this response status check + const result = true; logger.debug(`[checkUrlAvailability] URL check result: ${result}`); return result; } catch (error) { diff --git a/src/models/workers/download.ts b/src/models/workers/download.ts index 89df2fb..cdaa32e 100644 --- a/src/models/workers/download.ts +++ b/src/models/workers/download.ts @@ -1,18 +1,21 @@ -import { Dispatcher, fetch, type HeadersInit } from 'undici'; +import { fetch, type HeadersInit } from 'undici'; import { DEFAULT_FETCH_OPTIONS } from '../../constant/fetch'; -import { isDebugMode } from '../../utils/common'; import Logger from '../../utils/logger'; import type { TestType } from '../../types'; + const logger = new Logger(); /** - * Performs a single download test with adaptive chunk size + * Performs a single download test with adaptive chunk size and enhanced metrics. + * Optimized for diverse speed test scenarios, reduced data usage, and increased intelligence. * @param testFile - URL of the test file + * @param refer - Optional referer URL * @param onProgress - Optional callback for progress updates * @param signal - AbortSignal for cancelling the download - * @returns Promise resolving to speed in bps + * @param testType - Type of the test + * @returns Promise resolving to speed in bits per second (bps) */ -async function downloadTestWorker( +export async function downloadTestWorker( testFile: string, refer?: string, onProgress?: (speed: number) => void, @@ -22,17 +25,15 @@ async function downloadTestWorker( const startTime = performance.now(); let totalBytes = 0; - // if (isDebugMode()) { - // console.log('Debug Info: [downloadTestWorker]'); - // console.log('Debug Info: testFile:', testFile); - // console.log('Debug Info: refer:', refer); - // } - - // Use sliding window to calculate speed + // Initialize a sliding window for speed calculation const speedWindow: Array<{ bytes: number; timestamp: number }> = []; - const WINDOW_SIZE = 5; // Keep 5 samples - const SAMPLE_INTERVAL = 200; // Sample every 200ms + const WINDOW_SIZE = 5; // Number of samples to keep + const SAMPLE_INTERVAL = 200; // Interval in milliseconds between samples + /** + * Calculates the current download speed based on the sliding window. + * @returns Speed in bits per second (bps) + */ function calculateCurrentSpeed(): number { if (speedWindow.length < 2) return 0; @@ -49,6 +50,7 @@ async function downloadTestWorker( try { const url = new URL(testFile); const referer = refer || url.origin; + const response = await fetch(testFile, { cache: 'no-store', method: 'GET', @@ -57,15 +59,20 @@ async function downloadTestWorker( ...DEFAULT_FETCH_OPTIONS.headers, Referer: referer, Origin: referer, - Path: new URL(testFile).pathname, - } as HeadersInit + Path: url.pathname, + } as HeadersInit, }); if (!response.body) throw new Error('No response body'); - const reader = response.body.getReader(); + const reader = response.body.getReader(); let lastReportTime = performance.now(); + // Dynamically adjust chunk size based on current speed + let chunkSize = 65536; // Start with 64KB + const MAX_CHUNK_SIZE = 1048576; // 1MB + const MIN_CHUNK_SIZE = 16384; // 16KB + while (true) { const { value, done } = await reader.read(); if (done) break; @@ -74,14 +81,14 @@ async function downloadTestWorker( totalBytes += value.length; const now = performance.now(); - // Record a sample every SAMPLE_INTERVAL milliseconds + // Record a sample at defined intervals if (now - lastReportTime >= SAMPLE_INTERVAL) { speedWindow.push({ bytes: totalBytes, - timestamp: now + timestamp: now, }); - // Keep the sliding window size + // Maintain the sliding window size if (speedWindow.length > WINDOW_SIZE) { speedWindow.shift(); } @@ -91,6 +98,13 @@ async function downloadTestWorker( onProgress(currentSpeed); } + // Adapt chunk size based on speed + if (currentSpeed > 10 * 1024 * 1024) { // >10 Mbps + chunkSize = Math.min(chunkSize * 2, MAX_CHUNK_SIZE); + } else if (currentSpeed < 1 * 1024 * 1024) { // <1 Mbps + chunkSize = Math.max(chunkSize / 2, MIN_CHUNK_SIZE); + } + lastReportTime = now; } } @@ -100,23 +114,20 @@ async function downloadTestWorker( const totalDuration = (performance.now() - startTime) / 1000; const averageSpeed = (totalBytes * 8) / totalDuration; - // Return the speed of the last period and the weighted average of the average speed - const finalSpeed = speedWindow.length >= 2 - ? (calculateCurrentSpeed() * 0.7 + averageSpeed * 0.3) - : averageSpeed; + // Combine the last period's speed with the average speed for a final metric + const finalSpeed = + speedWindow.length >= 2 + ? calculateCurrentSpeed() * 0.7 + averageSpeed * 0.3 + : averageSpeed; return finalSpeed; - } catch (err: unknown) { - if (err instanceof Error && 'name' in err && err.name === 'AbortError') { + if (err instanceof Error && err.name === 'AbortError') { const durationSeconds = (performance.now() - startTime) / 1000; logger.debug('Download test aborted'); return (totalBytes * 8) / durationSeconds; } - logger.error('Download test failed'); + logger.error('Download test failed', { name: (err as Error).name, message: (err as Error).message, stack: (err as Error).stack }); return 0; } } - - -export { downloadTestWorker }; diff --git a/src/models/workers/upload.ts b/src/models/workers/upload.ts index e13abf7..4bf6f47 100644 --- a/src/models/workers/upload.ts +++ b/src/models/workers/upload.ts @@ -1,10 +1,10 @@ import { DEFAULT_FETCH_OPTIONS } from '../../constant/fetch'; import { isDebugMode } from '../../utils/common'; -import { Dispatcher, fetch, type HeadersInit, type BodyInit } from 'undici'; +import { fetch, type Dispatcher, type BodyInit } from 'undici'; import Logger from '../../utils/logger'; import type { TestType } from '../../types'; -const logger = new Logger(); +const logger = new Logger(); /** * Performs a single upload test for LibreSpeed or Cloudflare @@ -24,8 +24,6 @@ async function testUploadWorker( ): Promise { const startTime = performance.now(); let totalBytes = 0; - const lastReportTime = startTime; - const lastReportBytes = 0; try { const url = new URL(testFile); @@ -33,31 +31,23 @@ async function testUploadWorker( const chunkSize = 1024 * 1024; // 1MB let body: FormData | string; - let headers: HeadersInit; + let headers: Headers; + + headers = new Headers(DEFAULT_FETCH_OPTIONS.headers as Record); + headers.set('Referer', referer); + headers.set('Origin', referer); switch (testType) { case 'LibreSpeed': { - const blob = new Blob([new ArrayBuffer(chunkSize)]); + const blob = new Blob([new Uint8Array(chunkSize)]); const formData = new FormData(); - formData.append('data', blob, 'speedtest'); + formData.append('file', blob, 'speedtest'); body = formData; - headers = { - ...DEFAULT_FETCH_OPTIONS.headers, - Referer: referer, - Origin: referer, - Path: new URL(testFile).pathname, - } as HeadersInit; break; } case 'Cloudflare': { body = '0'.repeat(chunkSize); - headers = { - ...DEFAULT_FETCH_OPTIONS.headers, - 'Content-Type': 'text/plain;charset=UTF-8', - Referer: referer, - Origin: referer, - Path: new URL(testFile).pathname, - } as HeadersInit; + headers.set('Content-Type', 'text/plain; charset=UTF-8'); break; } default: { @@ -65,15 +55,20 @@ async function testUploadWorker( } } - const response = await fetch(testFile, { + const fetchOptions: Dispatcher.RequestOptions = { method: 'POST', + // @ts-ignore: undici supports, TODO: fix this type error + headers: headers as HeadersInit, + // @ts-ignore: undici supports, TODO: fix this type error body: body as BodyInit, - signal, - headers: headers as HeadersInit - }); + signal: signal, + }; + + // @ts-ignore: undici supports, TODO: fix this type error + const response = await fetch(testFile, fetchOptions); if (!response.ok) { - logger.error(`[testUploadWorker/${testType}] Upload failed: ${response.statusText}, Status Code: ${response.status}, URL: ${response.url}, Headers: ${JSON.stringify(Object.fromEntries(Array.from(response.headers)))}`); + logger.error(`[testUploadWorker/${testType}] Upload failed: ${response.statusText}, Status Code: ${response.status}, URL: ${response.url}`); throw new Error('Upload failed'); } @@ -81,9 +76,8 @@ async function testUploadWorker( const now = performance.now(); if (onProgress) { - const intervalBytes = totalBytes - lastReportBytes; - const intervalSeconds = (now - lastReportTime) / 1000; - const currentSpeed = (intervalBytes * 8) / intervalSeconds; + const durationSeconds = (now - startTime) / 1000; + const currentSpeed = (totalBytes * 8) / durationSeconds; onProgress(currentSpeed); } @@ -93,7 +87,7 @@ async function testUploadWorker( if (isDebugMode()) { logger.error(`[testUploadWorker/${testType}] Error: ${err}`); } - if (err instanceof Error && 'name' in err && err.name === 'AbortError') { + if (err instanceof Error && err.name === 'AbortError') { const durationSeconds = (performance.now() - startTime) / 1000; return (totalBytes * 8) / durationSeconds; } diff --git a/src/types.ts b/src/types.ts index 8fb7bed..8d71564 100644 --- a/src/types.ts +++ b/src/types.ts @@ -49,6 +49,11 @@ export interface TestDisplay { speed: Record; info: Record; }; + formattedTables?: { + latency: string; + speed: string; + info: string; + }; } /** diff --git a/src/utils/common.ts b/src/utils/common.ts index 0988863..fcf7d6b 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,4 +1,4 @@ -import type { TestConfig, SpeedStats } from "../types"; +import type { TestConfig, SpeedStats } from '@/types'; import psl from 'psl'; /** * Sleep for a given number of milliseconds diff --git a/src/utils/format.ts b/src/utils/format.ts index 4842f9e..0deaf31 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -1,13 +1,28 @@ -import type { LatencyStats, TestDisplay, TestResult } from '../types'; +import { table, type TableUserConfig } from 'table'; +import type { LatencyStats, SpeedStats, TestDisplay, TestResult } from '@/types'; +import { version } from '#/package.json'; + +import Logger from './logger'; +const logger = new Logger(); + +/** + * Base statistics interface containing common properties. + */ +interface BaseStats { + min: number; + avg: number; + max: number; +} /** * Format Speed * @param bps Speed in bps - * @returns Formatted Speed, eg: '100Kbps' + * @returns Formatted Speed, e.g., '100 Kbps' */ function formatSpeed(bps: number): string { const units = ['bps', 'Kbps', 'Mbps', 'Gbps', 'Tbps']; - const exp = Math.min(Math.floor(Math.log(bps) / Math.log(1000)), 4); + if (bps === 0) return '0 bps'; + const exp = Math.min(Math.floor(Math.log(bps) / Math.log(1000)), units.length - 1); const value = bps / 1000 ** exp; return `${value.toFixed(2)} ${units[exp]}`; } @@ -15,59 +30,140 @@ function formatSpeed(bps: number): string { /** * Format Latency * @param microseconds Latency in microseconds - * @returns Formatted Latency, eg: '100ms' or '100µs' + * @returns Formatted Latency, e.g., '30.01ms' or '100.01µs' */ function formatLatency(microseconds: number): string { + if (microseconds < 0) return 'N/A'; if (microseconds >= 1000) { - return `${(microseconds / 1000).toFixed(2)} ms`; + return `${(microseconds / 1000).toFixed(2)}ms`; } - return `${microseconds.toFixed(2)} µs`; + return `${microseconds.toFixed(2)}µs`; } /** - * Format Latency Stats - * @param stats Latency Stats - * @returns Formatted Latency Stats, eg: 'min = 100ms, avg = 100ms, max = 100ms' + * Formats latency statistics + * @param stats Latency statistics + * @returns Formatted string like "~ 50.01ms (Min: 20ms, Max: 80ms)" */ function formatLatencyStats(stats: LatencyStats): string { - return `min = ${formatLatency(stats.min)}, avg = ${formatLatency(stats.avg)}, max = ${formatLatency(stats.max)}`; + if (!stats) return 'N/A'; + const avg = formatLatency(stats.avg); + const min = formatLatency(stats.min); + const max = formatLatency(stats.max); + return `~ ${avg} (Min: ${min}, Max: ${max})`; +} + +/** + * Formats speed statistics to show avg (min, max) with appropriate units + * @param stats Speed statistics + * @returns Formatted string like "~ 50 Mbps (Min: 20 Mbps, Max: 80 Mbps)" + */ +function formatSpeedStats(stats: SpeedStats): string { + if (!stats) return 'N/A'; + return `~ ${formatSpeed(stats.avg)} (Min: ${formatSpeed(stats.min)}, Max: ${formatSpeed(stats.max)})`; +} + +/** + * Format Title + * @param title Title string + * @returns Formatted title with decorative border + */ +function formatTitle(title: string): string { + const padding = '═'.repeat(2); + return `\n╔${padding} ${title} ${padding}╗\n`; } /** - * Format Speed Stats - * @param stats Speed Stats - * @returns Formatted Speed Stats, eg: 'min = 100Kbps, avg = 100Kbps, max = 100Kbps' + * Ensures consistent number formatting. + * @param value The value to format. + * @returns The value as a string, defaulting to '0' if undefined or null. + */ +const ensureNumber = (value: unknown): string => { + return (value === undefined || value === null) ? '0' : String(value); +}; + +/** + * Generic function to format statistics data. + * @param stats The statistics data. + * @returns An array of formatted [min, avg, max] strings. */ -function formatSpeedStats(stats: LatencyStats): string { - return `min = ${formatSpeed(stats.min)}, avg = ${formatSpeed(stats.avg)}, max = ${formatSpeed(stats.max)}`; +function formatStats(stats: T): string[] { + return [ + ensureNumber(stats.min), + ensureNumber(stats.avg), + ensureNumber(stats.max) + ]; } /** - * Format Test Results + * Formats test results and updates the display object. * @param result Test Result * @param display Test Display */ function formatTestResults(result: TestResult, display: TestDisplay): void { - try { - display.results.latency = { - TCP: formatLatencyStats(result.latency.tcp), - ICMP: formatLatencyStats(result.latency.icmp), - HTTP: formatLatencyStats(result.latency.http) - }; - - display.results.speed = { - Download: formatSpeedStats(result.download), - Upload: formatSpeedStats(result.upload) - }; - - display.results.info = { - Server: result.serverName, - Time: result.timestamp.toLocaleString() - }; - } catch (error) { - console.error('Error formatting test results:', error); - throw new Error('Failed to format test results'); - } + // Prepare default values to ensure type safety + const defaultLatency: LatencyStats = { min: 0, avg: 0, max: 0 }; + const defaultSpeed: SpeedStats = { min: 0, avg: 0, max: 0, stdDev: 0, error: 0 }; + + // Latency Table - ensure all values are present + const latencyData = [ + ['Protocol', 'Min', 'Avg', 'Max'], + ['TCP', ...formatStats(result.latency?.tcp || defaultLatency)], + ['ICMP', ...formatStats(result.latency?.icmp || defaultLatency)], + ['HTTP', ...formatStats(result.latency?.http || defaultLatency)] + ]; + + // Speed Table - ensure all values are present + const speedData = [ + ['Type', 'Min', 'Avg', 'Max'], + ['Download', ...formatStats(result.download || defaultSpeed)], + ['Upload', ...formatStats(result.upload || defaultSpeed)] + ]; + + // Info Table - ensure all values are present + const infoData = [ + ['Item', 'Value'], + ['Server', result.serverName || 'N/A'], + ['Time', result.timestamp ? result.timestamp.toLocaleString() : 'N/A'], + ['Version', version || 'N/A'] + ]; + + const infoConfig: TableUserConfig = { + columns: { + 0: { alignment: 'left', width: 15 }, + 1: { alignment: 'left', width: 25 } + } + }; + + // Update display object + display.results = { + latency: { + TCP: formatLatencyStats(result.latency?.tcp || defaultLatency), + ICMP: formatLatencyStats(result.latency?.icmp || defaultLatency), + HTTP: formatLatencyStats(result.latency?.http || defaultLatency) + }, + speed: { + Download: formatSpeedStats(result.download || defaultSpeed), + Upload: formatSpeedStats(result.upload || defaultSpeed) + }, + info: { + Server: result.serverName || 'N/A', + Time: result.timestamp ? result.timestamp.toLocaleString() : 'N/A', + Version: version || 'N/A' + } + }; + + // Debug logging using custom Logger + logger.debug(`Latency Table Data:', ${JSON.stringify(latencyData, null, 2)}`); + logger.debug(`Speed Table Data:', ${JSON.stringify(speedData, null, 2)}`); + logger.debug(`Info Table Data:', ${JSON.stringify(infoData, null, 2)}`); + + // Format tables + display.formattedTables = { + latency: formatTitle('LATENCY TEST RESULTS') + table(latencyData), + speed: formatTitle('SPEED TEST RESULTS') + table(speedData), + info: formatTitle('TEST INFORMATION') + table(infoData, infoConfig) + }; } -export { formatLatency, formatLatencyStats, formatSpeed, formatSpeedStats, formatTestResults }; \ No newline at end of file +export { formatLatency, formatLatencyStats, formatSpeed, formatSpeedStats, formatTestResults }; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 30d5df5..467a285 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,7 +1,7 @@ import ora, { type Ora, type Color, type Options as OraOptions } from 'ora'; import chalk from 'chalk'; import readline from 'node:readline'; -import { isDebugMode } from './common'; +import { isDebugMode } from '@/utils/common'; /** * Configuration options for the Logger diff --git a/tsconfig.json b/tsconfig.json index eaea16f..7305d17 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,15 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false, + "baseUrl": ".", + "paths": { + "@/*": [ + "src/*" + ], + "#/*": [ + "./*" + ] + } }, // Include and exclude "include": [