Compare commits

..

9 Commits

Author SHA1 Message Date
Juergen Kunz
d1ab85cbb3 feat(port-management): add findFreePort method for automatic port discovery within a range
Some checks failed
Default (tags) / security (push) Successful in 46s
Default (tags) / test (push) Failing after 6m10s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-31 22:04:20 +00:00
9cf4e433bf 4.0.2
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 15m18s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-05-19 09:15:42 +00:00
7c88ecd82a fix(tests): Update dev dependencies and refactor test assertions for improved clarity 2025-05-19 09:15:42 +00:00
771bfe94e7 4.0.1
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 1m34s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-05-03 18:56:00 +00:00
def467a27b fix(formatting): Fix minor formatting issues and newline consistency across project files 2025-05-03 18:56:00 +00:00
7d087e39ef 4.0.0 2025-04-28 19:27:13 +00:00
26e1d5142a BREAKING CHANGE(smartnetwork): Enhance documentation and add configurable speed test options with plugin architecture improvements 2025-04-28 19:27:13 +00:00
d6be2e27b0 3.0.5 2025-04-28 15:30:08 +00:00
d6c0af35fa fix(core): Improve logging and error handling by introducing custom error classes and a global logging interface while refactoring network diagnostics methods. 2025-04-28 15:30:08 +00:00
15 changed files with 1683 additions and 1891 deletions

View File

@@ -1,5 +1,46 @@
# Changelog # Changelog
## 2025-07-31 - 4.1.0 - feat(port-management)
Add findFreePort method for automatic port discovery within a range
- Added new `findFreePort` method to SmartNetwork class that finds the first available port in a specified range
- Added comprehensive tests for the new port finding functionality
- Updated README documentation with usage examples for the new feature
- Improved port management capabilities for dynamic port allocation scenarios
## 2025-05-19 - 4.0.2 - fix(tests)
Update dev dependencies and refactor test assertions for improved clarity
- Bumped @git.zone/tsbuild version to ^2.5.1
- Bumped @git.zone/tstest version to ^1.9.0
- Updated npm test script to include the verbose flag
- Replaced expectAsync assertions with resolves based assertions in test files
## 2025-05-03 - 4.0.1 - fix(formatting)
Fix minor formatting issues and newline consistency across project files
- Ensure newline at end of package.json, errors.ts, logging.ts, and test files
- Refine code block formatting in readme.md
- Adjust whitespace and code style in smartnetwork classes and cloudflarespeed module
- Minor commitinfo data format update
## 2025-04-28 - 4.0.0 - BREAKING CHANGE(smartnetwork)
Enhance documentation and add configurable speed test options with plugin architecture improvements
- Expanded README with detailed examples for traceroute, speed test, ping, and remote port checks
- Added optional parameters for getSpeed (parallelStreams and duration) to allow configurable testing modes
- Included plugin architecture usage examples to show runtime registration and unregistration of plugins
- Updated test suite to cover DNS resolution, endpoint health-check, caching behavior, and various network diagnostics
- Removed legacy planning documentation from readme.plan.md
## 2025-04-28 - 3.0.5 - fix(core)
Improve logging and error handling by introducing custom error classes and a global logging interface while refactoring network diagnostics methods.
- Added custom error classes (NetworkError, TimeoutError) for network operations.
- Introduced a global logging interface to replace direct console logging.
- Updated CloudflareSpeed and SmartNetwork classes to use getLogger for improved error reporting.
- Disabled connection pooling in HTTP requests to prevent listener accumulation.
## 2025-04-28 - 3.0.4 - fix(ci/config) ## 2025-04-28 - 3.0.4 - fix(ci/config)
Improve CI workflows, update project configuration, and clean up code formatting Improve CI workflows, update project configuration, and clean up code formatting

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartnetwork", "name": "@push.rocks/smartnetwork",
"version": "3.0.4", "version": "4.1.0",
"private": false, "private": false,
"description": "A toolkit for network diagnostics including speed tests, port availability checks, and more.", "description": "A toolkit for network diagnostics including speed tests, port availability checks, and more.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@@ -9,17 +9,16 @@
"author": "Lossless GmbH", "author": "Lossless GmbH",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"test": "(tstest test/)", "test": "(tstest test/ --verbose)",
"build": "(tsbuild --web --allowimplicitany)", "build": "(tsbuild --web --allowimplicitany)",
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.1.61", "@git.zone/tsbuild": "^2.5.1",
"@git.zone/tsrun": "^1.2.39", "@git.zone/tsrun": "^1.2.39",
"@git.zone/tstest": "^1.0.69", "@git.zone/tstest": "^1.9.0",
"@push.rocks/smartenv": "^5.0.0", "@push.rocks/smartenv": "^5.0.0",
"@push.rocks/tapbundle": "^5.0.3", "@types/node": "^22.15.19"
"@types/node": "^22.15.3"
}, },
"dependencies": { "dependencies": {
"@push.rocks/smartping": "^1.0.7", "@push.rocks/smartping": "^1.0.7",

2197
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,73 @@
# Project Analysis
## Architecture Overview
This is a comprehensive network diagnostics toolkit that provides various network-related utilities. The main entry point is the `SmartNetwork` class which orchestrates all functionality.
Key features:
- Speed testing via Cloudflare (parallelizable with duration support)
- Ping operations with statistics
- Port availability checks (local and remote)
- Network gateway discovery
- Public IP retrieval
- DNS resolution
- HTTP endpoint health checks
- Traceroute functionality (with fallback stub)
## Key Components
### SmartNetwork Class
- Central orchestrator for all network operations
- Supports caching via `cacheTtl` option for gateway and public IP lookups
- Plugin architecture for extensibility
### CloudflareSpeed Class
- Handles internet speed testing using Cloudflare's infrastructure
- Supports parallel streams and customizable test duration
- Measures both download and upload speeds using progressive chunk sizes
- Includes latency measurements (jitter, median, average)
### Error Handling
- Custom `NetworkError` and `TimeoutError` classes for better error context
- Error codes follow Node.js conventions (ENOTSUP, EINVAL, ETIMEOUT)
### Logging
- Global logger interface for consistent logging across the codebase
- Replaceable logger implementation (defaults to console)
- Used primarily for error reporting in speed tests
### Statistics Helpers
- Utility functions for statistical calculations (average, median, quartile, jitter)
- Used extensively by speed testing and ping operations
## Recent Changes (v4.0.0 - v4.0.1)
- Added configurable speed test options (parallelStreams, duration)
- Introduced plugin architecture for runtime extensibility
- Enhanced error handling with custom error classes
- Added global logging interface
- Improved connection management by disabling HTTP connection pooling
- Fixed memory leaks from listener accumulation
- Minor formatting fixes for consistency
## Testing
- Comprehensive test suite covering all major features
- Tests run on both browser and node environments
- Uses @push.rocks/tapbundle for testing with expectAsync
- Performance tests for speed testing functionality
- Edge case handling for network errors and timeouts
## Technical Details
- ESM-only package (module type)
- TypeScript with strict typing
- Depends on external modules for specific functionality:
- @push.rocks/smartping for ICMP operations
- public-ip for external IP discovery
- systeminformation for network interface details
- isopen for remote port checking
- Uses native Node.js modules for DNS, HTTP/HTTPS, and network operations
## Design Patterns
- Factory pattern for plugin registration
- Caching pattern with TTL for expensive operations
- Promise-based async/await throughout
- Deferred promises for complex async coordination
- Error propagation with custom error types

301
readme.md
View File

@@ -1,6 +1,6 @@
# @push.rocks/smartnetwork # @push.rocks/smartnetwork
network diagnostics Comprehensive network diagnostics and utilities for Node.js applications
## Install ## Install
@@ -10,11 +10,9 @@ To install `@push.rocks/smartnetwork`, run the following command in your termina
npm install @push.rocks/smartnetwork --save npm install @push.rocks/smartnetwork --save
``` ```
This command will download `@push.rocks/smartnetwork` and add it to your project's `package.json` file.
## Usage ## Usage
In this section, we will dive deep into the capabilities of the `@push.rocks/smartnetwork` package, exploring its various features through TypeScript examples. The package is designed to simplify network diagnostics tasks, including speed tests, port availability checks, ping operations, and more. The `@push.rocks/smartnetwork` package provides a comprehensive suite of network diagnostic tools including speed tests, port availability checks, ping operations, DNS resolution, HTTP endpoint health checks, and more.
### Basic Setup ### Basic Setup
@@ -28,111 +26,294 @@ Then, create an instance of `SmartNetwork`:
```typescript ```typescript
const myNetwork = new SmartNetwork(); const myNetwork = new SmartNetwork();
// Or with caching enabled (60 seconds TTL)
const myNetworkCached = new SmartNetwork({ cacheTtl: 60000 });
``` ```
### Performing a Speed Test ### Network Speed Testing
You can measure the network speed using the `getSpeed` method. This feature leverages Cloudflare's speed test capabilities to assess your internet connection's download and upload speeds. Measure network download and upload speeds using Cloudflare's speed test infrastructure:
```typescript ```typescript
const speedTest = async () => { const speedTest = async () => {
const speedResult = await myNetwork.getSpeed(); // Basic speed test
console.log(`Download speed: ${speedResult.downloadSpeed} Mbps`); const result = await myNetwork.getSpeed();
console.log(`Upload speed: ${speedResult.uploadSpeed} Mbps`); console.log(`Download: ${result.downloadSpeed} Mbps`);
console.log(`Latency: ${speedResult.averageTime} ms`); console.log(`Upload: ${result.uploadSpeed} Mbps`);
};
speedTest(); // Advanced speed test with options
const advancedResult = await myNetwork.getSpeed({
parallelStreams: 3, // Number of concurrent connections
duration: 5 // Test duration in seconds
});
console.log(`Download: ${advancedResult.downloadSpeed} Mbps`);
console.log(`Upload: ${advancedResult.uploadSpeed} Mbps`);
};
``` ```
### Checking Port Availability Locally ### Port Management
The `isLocalPortUnused` method allows you to check if a specific port on your local machine is available for use. #### Check Local Port Availability
Verify if a specific port is available on your local machine (checks both IPv4 and IPv6):
```typescript ```typescript
const checkLocalPort = async (port: number) => { const checkLocalPort = async (port: number) => {
const isUnused = await myNetwork.isLocalPortUnused(port); const isUnused = await myNetwork.isLocalPortUnused(port);
if (isUnused) { if (isUnused) {
console.log(`Port ${port} is available.`); console.log(`Port ${port} is available`);
} else { } else {
console.log(`Port ${port} is in use.`); console.log(`Port ${port} is in use`);
} }
}; };
checkLocalPort(8080); // Example port number await checkLocalPort(8080);
``` ```
### Checking Remote Port Availability #### Find Free Port in Range
To verify if a certain port is available on a remote server, use `isRemotePortAvailable`. This can help determine if a service is up and reachable. Automatically find the first available port within a specified range:
```typescript ```typescript
const checkRemotePort = async (hostname: string, port: number) => { const findFreePort = async () => {
const isAvailable = await myNetwork.isRemotePortAvailable(hostname, port); // Find a free port between 3000 and 3100
if (isAvailable) { const freePort = await myNetwork.findFreePort(3000, 3100);
console.log(`Port ${port} on ${hostname} is available.`);
if (freePort) {
console.log(`Found free port: ${freePort}`);
} else { } else {
console.log(`Port ${port} on ${hostname} is not available.`); console.log('No free ports available in the specified range');
} }
}; };
checkRemotePort('example.com', 443); // Checking HTTPS port on example.com
``` ```
### Using Ping #### Check Remote Port Availability
The `ping` method allows you to send ICMP packets to a host to measure round-trip time and determine if the host is reachable. Verify if a port is open on a remote server:
```typescript ```typescript
const pingHost = async (hostname: string) => { // Method 1: Using "host:port" syntax
const pingResult = await myNetwork.ping(hostname); const isOpen1 = await myNetwork.isRemotePortAvailable('example.com:443');
if (pingResult.alive) {
console.log(`${hostname} is reachable. RTT: ${pingResult.time} ms`); // Method 2: Using separate host and port
} else { const isOpen2 = await myNetwork.isRemotePortAvailable('example.com', 443);
console.log(`${hostname} is not reachable.`);
// Method 3: With options (retries, timeout)
const isOpen3 = await myNetwork.isRemotePortAvailable('example.com', {
port: 443,
protocol: 'tcp', // Only TCP is supported
retries: 3, // Number of connection attempts
timeout: 5000 // Timeout per attempt in ms
});
// Note: UDP is not supported and will throw an error
try {
await myNetwork.isRemotePortAvailable('example.com', {
port: 53,
protocol: 'udp'
});
} catch (e) {
console.error(e.code); // ENOTSUP
}
```
### Network Connectivity
#### Ping Operations
Send ICMP echo requests to test connectivity and measure latency:
```typescript
// Simple ping
const pingResult = await myNetwork.ping('google.com');
console.log(`Host alive: ${pingResult.alive}`);
console.log(`RTT: ${pingResult.time} ms`);
// Ping with statistics (multiple pings)
const pingStats = await myNetwork.ping('google.com', {
count: 5, // Number of pings
timeout: 1000 // Timeout per ping in ms
});
console.log(`Packet loss: ${pingStats.packetLoss}%`);
console.log(`Min: ${pingStats.min} ms`);
console.log(`Max: ${pingStats.max} ms`);
console.log(`Avg: ${pingStats.avg.toFixed(2)} ms`);
console.log(`Stddev: ${pingStats.stddev.toFixed(2)} ms`);
```
#### Traceroute
Perform hop-by-hop network path analysis:
```typescript
const hops = await myNetwork.traceroute('google.com', {
maxHops: 10, // Maximum number of hops
timeout: 5000 // Timeout in ms
});
hops.forEach(hop => {
const rtt = hop.rtt === null ? '*' : `${hop.rtt} ms`;
console.log(`${hop.ttl}\t${hop.ip}\t${rtt}`);
});
```
Note: Falls back to a single-hop stub if the `traceroute` binary is unavailable on the system.
### DNS Operations
Resolve DNS records for a hostname:
```typescript
const dnsRecords = await myNetwork.resolveDns('example.com');
console.log('A records:', dnsRecords.A); // IPv4 addresses
console.log('AAAA records:', dnsRecords.AAAA); // IPv6 addresses
console.log('MX records:', dnsRecords.MX); // Mail servers
// MX records include priority
dnsRecords.MX.forEach(mx => {
console.log(`Mail server: ${mx.exchange} (priority: ${mx.priority})`);
});
```
### HTTP/HTTPS Endpoint Health Checks
Check the health and response time of HTTP/HTTPS endpoints:
```typescript
const health = await myNetwork.checkEndpoint('https://example.com', {
timeout: 5000 // Request timeout in ms
});
console.log(`Status: ${health.status}`);
console.log(`RTT: ${health.rtt} ms`);
console.log('Headers:', health.headers);
```
### Network Interface Information
#### Get Network Gateways
List all network interfaces on the system:
```typescript
const gateways = await myNetwork.getGateways();
Object.entries(gateways).forEach(([name, interfaces]) => {
console.log(`Interface: ${name}`);
interfaces.forEach(iface => {
console.log(` ${iface.family}: ${iface.address}`);
console.log(` Netmask: ${iface.netmask}`);
console.log(` MAC: ${iface.mac}`);
});
});
```
#### Get Default Gateway
Retrieve the system's default network gateway:
```typescript
const defaultGateway = await myNetwork.getDefaultGateway();
if (defaultGateway) {
console.log('IPv4 Gateway:', defaultGateway.ipv4.address);
console.log('IPv6 Gateway:', defaultGateway.ipv6.address);
}
```
### Public IP Discovery
Discover your public IPv4 and IPv6 addresses:
```typescript
const publicIps = await myNetwork.getPublicIps();
console.log(`Public IPv4: ${publicIps.v4 || 'Not available'}`);
console.log(`Public IPv6: ${publicIps.v6 || 'Not available'}`);
```
### Caching
SmartNetwork supports caching for gateway and public IP lookups to reduce repeated network calls:
```typescript
// Create instance with 60-second cache TTL
const cachedNetwork = new SmartNetwork({ cacheTtl: 60000 });
// These calls will use cached results if called within 60 seconds
const gateways1 = await cachedNetwork.getGateways();
const publicIps1 = await cachedNetwork.getPublicIps();
// Subsequent calls within TTL return cached results
const gateways2 = await cachedNetwork.getGateways(); // From cache
const publicIps2 = await cachedNetwork.getPublicIps(); // From cache
```
### Plugin Architecture
Extend SmartNetwork's functionality with custom plugins:
```typescript
// Define your plugin
class CustomNetworkPlugin {
constructor(private smartNetwork: SmartNetwork) {}
async customMethod() {
// Your custom network functionality
} }
}; }
pingHost('google.com'); // Register the plugin
SmartNetwork.registerPlugin('customPlugin', CustomNetworkPlugin);
// Use the plugin
const network = new SmartNetwork();
const plugin = new (SmartNetwork.pluginsRegistry.get('customPlugin'))(network);
await plugin.customMethod();
// Unregister when no longer needed
SmartNetwork.unregisterPlugin('customPlugin');
``` ```
### Getting Network Gateways ### Error Handling
You can also retrieve information about your network gateways, including the default gateway used by your machine. The package uses custom `NetworkError` class for network-related errors:
```typescript ```typescript
const showGateways = async () => { import { NetworkError } from '@push.rocks/smartnetwork';
const gateways = await myNetwork.getGateways();
console.log(gateways);
const defaultGateway = await myNetwork.getDefaultGateway(); try {
console.log(`Default Gateway: `, defaultGateway); await myNetwork.isRemotePortAvailable('example.com', { protocol: 'udp' });
}; } catch (error) {
if (error instanceof NetworkError) {
showGateways(); console.error(`Network error: ${error.message}`);
console.error(`Error code: ${error.code}`);
}
}
``` ```
### Discovering Public IP Addresses ### TypeScript Support
To find out your public IPv4 and IPv6 addresses, the following method can be used: This package is written in TypeScript and provides full type definitions. Key interfaces include:
```typescript ```typescript
const showPublicIps = async () => { interface SmartNetworkOptions {
const publicIps = await myNetwork.getPublicIps(); cacheTtl?: number; // Cache TTL in milliseconds
console.log(`Public IPv4: ${publicIps.v4}`); }
console.log(`Public IPv6: ${publicIps.v6}`);
};
showPublicIps(); interface Hop {
ttl: number; // Time to live
ip: string; // IP address of the hop
rtt: number | null; // Round trip time in ms
}
``` ```
The `@push.rocks/smartnetwork` package provides an easy-to-use, comprehensive suite of tools for network diagnostics and monitoring, encapsulating complex network operations into simple asynchronous methods. By leveraging TypeScript, developers can benefit from type checking, ensuring that they can work with clear structures and expectations.
These examples offer a glimpse into the module's utility in real-world scenarios, demonstrating its versatility in handling common network tasks. Whether you're developing a network-sensitive application, diagnosing connectivity issues, or simply curious about your network performance, `@push.rocks/smartnetwork` equips you with the tools you need.
## License and Legal Information ## License and Legal Information
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
@@ -147,4 +328,4 @@ Registered at District court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc. For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works. By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

@@ -1,47 +0,0 @@
# Plan to Enhance Code Quality, Feature Set & Documentation
This plan focuses on three pillars to elevate `@push.rocks/smartnetwork`: 1) Code Quality, 2) Feature Enhancements, and 3) Documentation.
## 1. Code Quality Improvements
- Enable strict TypeScript (`strict`, `noImplicitAny`, `strictNullChecks`).
- Enforce linting (ESLint) and formatting (Prettier) with pre-commit hooks.
- Audit and refactor core modules for:
- Clear separation of concerns (IO, business logic, helpers).
- Removal of duplicated logic and dead code.
- Consistent use of async/await and error propagation.
- Introduce custom error classes (e.g., `NetworkError`, `TimeoutError`) for predictable failure handling.
- Augment logging support via injectable logger interface.
- Establish a baseline of ≥90% unit-test coverage and enforce via CI.
## 2. Feature Enhancements
- Expand diagnostics:
- Traceroute functionality with hop-by-hop latency.
- DNS lookup (A, AAAA, MX records).
- HTTP(s) endpoint health check (status codes, headers, latency).
- Improve existing methods:
- `getSpeed`: allow configurable test duration and parallel streams.
- `ping`: add statistical summary (min, max, stddev) and continuous mode.
- `isRemotePortAvailable`: support TCP/UDP checks with timeout and retry.
- Introduce plugin architecture:
- Define `Plugin` interface for third-party extensions.
- Enable runtime registration/unregistration.
- Provide sample plugins (e.g., custom ping strategies, alternate speed providers).
- Optional in-memory caching with TTL for expensive calls (`getPublicIps`, `getGateways`).
## 3. Documentation & Examples
- Upgrade README:
- Detailed API reference with method signatures and option parameters.
- Real-world usage snippets and full example projects.
- Add TSDoc comments to all public classes, methods, and types.
- Create a `docs/` folder with:
- Getting Started guide.
- Advanced topics (plugin development, custom error handling).
- FAQ and troubleshooting section.
- Integrate TypeDoc for automated documentation site generation.
- Update `CONTRIBUTING.md` and `CHANGELOG.md` to reflect development and release practices.
## Next Steps
1. Review and prioritize high-impact items per pillar.
2. Kick off Phase 1 (Code Quality) with linting, TS config, and core refactor.
3. Schedule sprints for Feature and Documentation phases.
4. Configure CI pipeline to enforce quality gates and publish docs.

255
test/test.features.ts Normal file
View File

@@ -0,0 +1,255 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SmartNetwork, NetworkError } from '../ts/index.js';
import * as net from 'net';
import type { AddressInfo } from 'net';
// DNS resolution
tap.test('resolveDns should return A records for localhost', async () => {
const sn = new SmartNetwork();
const res = await sn.resolveDns('localhost');
expect(res.A.length).toBeGreaterThan(0);
expect(Array.isArray(res.A)).toBeTrue();
expect(Array.isArray(res.AAAA)).toBeTrue();
expect(Array.isArray(res.MX)).toBeTrue();
});
// DNS resolution edge cases and MX records
tap.test('resolveDns should handle non-existent domains', async () => {
const sn = new SmartNetwork();
const res = await sn.resolveDns('no.such.domain.invalid');
expect(Array.isArray(res.A)).toBeTrue();
expect(Array.isArray(res.AAAA)).toBeTrue();
expect(Array.isArray(res.MX)).toBeTrue();
expect(res.A.length).toEqual(0);
expect(res.AAAA.length).toEqual(0);
expect(res.MX.length).toEqual(0);
});
tap.test('resolveDns MX records for google.com', async () => {
const sn = new SmartNetwork();
const res = await sn.resolveDns('google.com');
expect(Array.isArray(res.MX)).toBeTrue();
if (res.MX.length > 0) {
expect(typeof res.MX[0].exchange).toEqual('string');
expect(typeof res.MX[0].priority).toEqual('number');
}
});
// HTTP endpoint health-check
tap.test('checkEndpoint should return status and headers', async () => {
const sn = new SmartNetwork();
const result = await sn.checkEndpoint('https://example.com');
expect(result.status).toEqual(200);
expect(typeof result.rtt).toEqual('number');
expect(typeof result.headers).toEqual('object');
expect(result.headers).toHaveProperty('content-type');
});
// Traceroute stub
tap.test('traceroute should return at least one hop', async () => {
const sn = new SmartNetwork();
const hops = await sn.traceroute('127.0.0.1');
expect(Array.isArray(hops)).toBeTrue();
expect(hops.length).toBeGreaterThanOrEqual(1);
const hop = hops[0];
expect(typeof hop.ttl).toEqual('number');
expect(typeof hop.ip).toEqual('string');
expect(hop.rtt === null || typeof hop.rtt === 'number').toBeTrue();
});
// Traceroute fallback stub ensures consistent output when binary missing
tap.test('traceroute fallback stub returns a single-hop stub', async () => {
const sn = new SmartNetwork();
const hops = await sn.traceroute('example.com', { maxHops: 5 });
expect(Array.isArray(hops)).toBeTrue();
expect(hops).array.toHaveLength(1);
expect(hops[0]).toEqual({ ttl: 1, ip: 'example.com', rtt: null });
});
// getSpeed options
tap.test('getSpeed should accept options and return speeds', async () => {
const opts = { parallelStreams: 2, duration: 1 };
const sn = new SmartNetwork();
const result = await sn.getSpeed(opts);
expect(typeof result.downloadSpeed).toEqual('string');
expect(typeof result.uploadSpeed).toEqual('string');
expect(parseFloat(result.downloadSpeed)).toBeGreaterThan(0);
expect(parseFloat(result.uploadSpeed)).toBeGreaterThan(0);
expect(parseFloat(result.downloadSpeed)).toBeGreaterThan(0);
expect(parseFloat(result.uploadSpeed)).toBeGreaterThan(0);
});
// Ping multiple count
tap.test('ping with count > 1 should return stats', async () => {
const sn = new SmartNetwork();
const stats = await sn.ping('127.0.0.1', { count: 3 });
expect(stats.count).toEqual(3);
expect(Array.isArray(stats.times)).toBeTrue();
expect(stats.times.length).toEqual(3);
expect(typeof stats.min).toEqual('number');
expect(typeof stats.max).toEqual('number');
expect(typeof stats.avg).toEqual('number');
expect(typeof stats.stddev).toEqual('number');
expect(typeof stats.packetLoss).toEqual('number');
expect(typeof stats.alive).toEqual('boolean');
});
// Remote port UDP not supported
// Remote port UDP not supported
tap.test('isRemotePortAvailable should throw on UDP', async () => {
const sn = new SmartNetwork();
// should throw NetworkError with code ENOTSUP when protocol is UDP
try {
await sn.isRemotePortAvailable('example.com', { protocol: 'udp' });
// If no error is thrown, the test should fail
throw new Error('Expected isRemotePortAvailable to throw for UDP');
} catch (err: any) {
expect(err).toBeInstanceOf(NetworkError);
expect(err.code).toEqual('ENOTSUP');
}
});
// Plugin registry
tap.test('should register and unregister plugin', async () => {
class Dummy {}
SmartNetwork.registerPlugin('dummy', Dummy);
expect(SmartNetwork.pluginsRegistry.has('dummy')).toBeTrue();
SmartNetwork.unregisterPlugin('dummy');
expect(SmartNetwork.pluginsRegistry.has('dummy')).toBeFalse();
});
tap.test('getGateways should respect cacheTtl', async () => {
const sn = new SmartNetwork({ cacheTtl: 1000 });
const first = await sn.getGateways();
const second = await sn.getGateways();
expect(first).toEqual(second);
});
// Remote port checks: missing port should error
tap.test('isRemotePortAvailable should require a port', async () => {
const sn = new SmartNetwork();
try {
await sn.isRemotePortAvailable('example.com');
throw new Error('Expected error when port is not specified');
} catch (err: any) {
expect(err).toBeInstanceOf(NetworkError);
expect(err.code).toEqual('EINVAL');
}
});
// Remote port checks: detect open TCP port on example.com
tap.test('isRemotePortAvailable should detect open TCP port via string target', async () => {
const sn = new SmartNetwork();
const open = await sn.isRemotePortAvailable('example.com:80');
expect(open).toBeTrue();
});
tap.test('isRemotePortAvailable should detect open TCP port via numeric arg', async () => {
const sn = new SmartNetwork();
const open = await sn.isRemotePortAvailable('example.com', 80);
expect(open).toBeTrue();
});
// Caching public IPs
tap.test('getPublicIps should respect cacheTtl', async () => {
const sn = new SmartNetwork({ cacheTtl: 1000 });
const first = await sn.getPublicIps();
const second = await sn.getPublicIps();
expect(first).toEqual(second);
});
// Local port usage detection
tap.test('isLocalPortUnused should detect used local port', async () => {
const sn = new SmartNetwork();
// start a server on a random port
const server = net.createServer();
await new Promise<void>((res) => server.listen(0, res));
const addr = server.address() as AddressInfo;
// port is now in use
const inUse = await sn.isLocalPortUnused(addr.port);
expect(inUse).toBeFalse();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
// findFreePort tests
tap.test('findFreePort should find an available port in range', async () => {
const sn = new SmartNetwork();
const freePort = await sn.findFreePort(49152, 49200);
expect(freePort).toBeGreaterThanOrEqual(49152);
expect(freePort).toBeLessThanOrEqual(49200);
// Verify the port is actually free
const isUnused = await sn.isLocalPortUnused(freePort);
expect(isUnused).toBeTrue();
});
tap.test('findFreePort should return null when all ports are occupied', async () => {
const sn = new SmartNetwork();
// Create servers to occupy a small range
const servers = [];
const startPort = 49300;
const endPort = 49302;
for (let port = startPort; port <= endPort; port++) {
const server = net.createServer();
await new Promise<void>((res) => server.listen(port, res));
servers.push(server);
}
// Now all ports in range should be occupied
const freePort = await sn.findFreePort(startPort, endPort);
expect(freePort).toBeNull();
// Clean up servers
await Promise.all(servers.map(s => new Promise<void>((res) => s.close(() => res()))));
});
tap.test('findFreePort should validate port range', async () => {
const sn = new SmartNetwork();
// Test invalid port numbers
try {
await sn.findFreePort(0, 100);
throw new Error('Expected error for port < 1');
} catch (err: any) {
expect(err).toBeInstanceOf(NetworkError);
expect(err.code).toEqual('EINVAL');
}
try {
await sn.findFreePort(100, 70000);
throw new Error('Expected error for port > 65535');
} catch (err: any) {
expect(err).toBeInstanceOf(NetworkError);
expect(err.code).toEqual('EINVAL');
}
// Test startPort > endPort
try {
await sn.findFreePort(200, 100);
throw new Error('Expected error for startPort > endPort');
} catch (err: any) {
expect(err).toBeInstanceOf(NetworkError);
expect(err.code).toEqual('EINVAL');
}
});
// Real traceroute integration test (skipped if `traceroute` binary is unavailable)
tap.test('traceroute real integration against google.com', async () => {
const sn = new SmartNetwork();
// detect traceroute binary
const { spawnSync } = await import('child_process');
const probe = spawnSync('traceroute', ['-h']);
if (probe.error || probe.status !== 0) {
// Skip real integration when traceroute is not installed
return;
}
const hops = await sn.traceroute('google.com', { maxHops: 5, timeout: 5000 });
expect(Array.isArray(hops)).toBeTrue();
expect(hops.length).toBeGreaterThan(1);
for (const hop of hops) {
expect(typeof hop.ttl).toEqual('number');
expect(typeof hop.ip).toEqual('string');
expect(hop.rtt === null || typeof hop.rtt === 'number').toBeTrue();
}
});
tap.start();

View File

@@ -1,4 +1,4 @@
import { tap, expect, expectAsync } from '@push.rocks/tapbundle'; import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as smartnetwork from '../ts/index.js'; import * as smartnetwork from '../ts/index.js';
@@ -10,16 +10,21 @@ tap.test('should create a vlid instance of SmartNetwork', async () => {
}); });
tap.test('should send a ping to Google', async () => { tap.test('should send a ping to Google', async () => {
console.log(await testSmartnetwork.ping('google.com')); const res = await testSmartnetwork.ping('google.com');
await expectAsync(testSmartnetwork.ping('google.com')).property('alive').toBeTrue(); console.log(res);
// verify basic ping response properties
expect(res.alive).toBeTrue();
expect(res.time).toBeTypeofNumber();
expect(res.output).toBeTypeofString();
expect(res.output).toMatch(/PING google\.com/);
}); });
tap.test('should state when a ping is not alive ', async () => { tap.test('should state when a ping is not alive ', async () => {
await expectAsync(testSmartnetwork.ping('notthere.lossless.com')).property('alive').toBeFalse(); await expect(testSmartnetwork.ping('notthere.lossless.com')).resolves.property('alive').toBeFalse();
}); });
tap.test('should send a ping to an IP', async () => { tap.test('should send a ping to an IP', async () => {
await expectAsync(testSmartnetwork.ping('192.168.186.999')).property('alive').toBeFalse(); await expect(testSmartnetwork.ping('192.168.186.999')).resolves.property('alive').toBeFalse();
}); });
tap.start(); tap.start();

View File

@@ -1,4 +1,4 @@
import { expect, expectAsync, tap } from '@push.rocks/tapbundle'; import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartnetwork from '../ts/index.js'; import * as smartnetwork from '../ts/index.js';
let testSmartNetwork: smartnetwork.SmartNetwork; let testSmartNetwork: smartnetwork.SmartNetwork;
@@ -12,31 +12,46 @@ tap.test('should perform a speedtest', async () => {
const result = await testSmartNetwork.getSpeed(); const result = await testSmartNetwork.getSpeed();
console.log(`Download speed for this instance is ${result.downloadSpeed}`); console.log(`Download speed for this instance is ${result.downloadSpeed}`);
console.log(`Upload speed for this instance is ${result.uploadSpeed}`); console.log(`Upload speed for this instance is ${result.uploadSpeed}`);
// verify speeds are returned as strings and parse to positive numbers
expect(typeof result.downloadSpeed).toEqual('string');
expect(typeof result.uploadSpeed).toEqual('string');
expect(parseFloat(result.downloadSpeed)).toBeGreaterThan(0);
expect(parseFloat(result.uploadSpeed)).toBeGreaterThan(0);
}); });
tap.test('should determine wether a port is free', async () => { tap.test('should determine wether a port is free', async () => {
await expectAsync(testSmartNetwork.isLocalPortUnused(8080)).toBeTrue(); await expect(testSmartNetwork.isLocalPortUnused(8080)).resolves.toBeTrue();
}); });
tap.test('should scan a port', async () => { tap.test('should scan a port', async () => {
await expectAsync(testSmartNetwork.isRemotePortAvailable('lossless.com:443')).toBeTrue(); await expect(testSmartNetwork.isRemotePortAvailable('lossless.com:443')).resolves.toBeTrue();
await expectAsync(testSmartNetwork.isRemotePortAvailable('lossless.com', 443)).toBeTrue(); await expect(testSmartNetwork.isRemotePortAvailable('lossless.com', 443)).resolves.toBeTrue();
await expectAsync(testSmartNetwork.isRemotePortAvailable('lossless.com:444')).toBeFalse(); await expect(testSmartNetwork.isRemotePortAvailable('lossless.com:444')).resolves.toBeFalse();
}); });
tap.test('should get gateways', async () => { tap.test('should get gateways', async () => {
const gatewayResult = await testSmartNetwork.getGateways(); const gateways = await testSmartNetwork.getGateways();
console.log(gatewayResult); console.log(gateways);
// verify gateways object has at least one interface
expect(typeof gateways).toEqual('object');
expect(Object.keys(gateways).length).toBeGreaterThan(0);
}); });
tap.test('should get the default gateway', async () => { tap.test('should get the default gateway', async () => {
const gatewayResult = await testSmartNetwork.getDefaultGateway(); const defaultGw = await testSmartNetwork.getDefaultGateway();
console.log(gatewayResult); console.log(defaultGw);
// verify default gateway contains ipv4 and ipv6 info
expect(defaultGw).toBeDefined();
expect(defaultGw.ipv4).toBeDefined();
expect(defaultGw.ipv6).toBeDefined();
}); });
tap.test('should get public ips', async () => { tap.test('should get public ips', async () => {
const ips = await testSmartNetwork.getPublicIps(); const ips = await testSmartNetwork.getPublicIps();
console.log(ips); console.log(ips);
// verify public IPs object contains v4 and v6 properties
expect(ips).toHaveProperty('v4');
expect(ips).toHaveProperty('v6');
}); });
tap.start(); tap.start();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartnetwork', name: '@push.rocks/smartnetwork',
version: '3.0.4', version: '4.0.2',
description: 'A toolkit for network diagnostics including speed tests, port availability checks, and more.' description: 'A toolkit for network diagnostics including speed tests, port availability checks, and more.'
} }

20
ts/errors.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Custom error classes for network operations
*/
export class NetworkError extends Error {
public code?: string;
constructor(message?: string, code?: string) {
super(message);
this.name = 'NetworkError';
this.code = code;
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class TimeoutError extends NetworkError {
constructor(message?: string) {
super(message, 'ETIMEOUT');
this.name = 'TimeoutError';
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@@ -1 +1,4 @@
export * from './smartnetwork.classes.smartnetwork.js'; export * from './smartnetwork.classes.smartnetwork.js';
export type { SmartNetworkOptions, Hop } from './smartnetwork.classes.smartnetwork.js';
export { setLogger, getLogger } from './logging.js';
export { NetworkError, TimeoutError } from './errors.js';

30
ts/logging.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* Injectable logging interface and global logger
*/
export interface Logger {
/** Debug-level messages */
debug?(...args: unknown[]): void;
/** Informational messages */
info(...args: unknown[]): void;
/** Warning messages */
warn?(...args: unknown[]): void;
/** Error messages */
error(...args: unknown[]): void;
}
let globalLogger: Logger = console;
/**
* Replace the global logger implementation
* @param logger Custom logger adhering to Logger interface
*/
export function setLogger(logger: Logger): void {
globalLogger = logger;
}
/**
* Retrieve the current global logger
*/
export function getLogger(): Logger {
return globalLogger;
}

View File

@@ -1,8 +1,17 @@
import * as plugins from './smartnetwork.plugins.js'; import * as plugins from './smartnetwork.plugins.js';
import { getLogger } from './logging.js';
import { NetworkError, TimeoutError } from './errors.js';
import * as stats from './helpers/stats.js'; import * as stats from './helpers/stats.js';
export interface SpeedOptions {
parallelStreams?: number;
duration?: number;
}
export class CloudflareSpeed { export class CloudflareSpeed {
constructor() {} private opts: SpeedOptions;
constructor(opts?: SpeedOptions) {
this.opts = opts || {};
}
public async speedTest() { public async speedTest() {
const latency = await this.measureLatency(); const latency = await this.measureLatency();
@@ -10,20 +19,71 @@ export class CloudflareSpeed {
const serverLocations = await this.fetchServerLocations(); const serverLocations = await this.fetchServerLocations();
const cgiData = await this.fetchCfCdnCgiTrace(); const cgiData = await this.fetchCfCdnCgiTrace();
// lets test the download speed // speed tests: either fixed segments or duration-based mode
const testDown1 = await this.measureDownload(101000, 10); const parallel = this.opts.parallelStreams ?? 1;
const testDown2 = await this.measureDownload(1001000, 8); const measureDownloadParallel = (bytes: number, iterations: number) => {
const testDown3 = await this.measureDownload(10001000, 6); if (parallel <= 1) {
const testDown4 = await this.measureDownload(25001000, 4); return this.measureDownload(bytes, iterations);
const testDown5 = await this.measureDownload(100001000, 1); }
const downloadTests = [...testDown1, ...testDown2, ...testDown3, ...testDown4, ...testDown5]; return Promise.all(
Array(parallel)
.fill(null)
.map(() => this.measureDownload(bytes, iterations)),
).then((arrays) => arrays.flat());
};
let downloadTests: number[];
if (this.opts.duration && this.opts.duration > 0) {
// duration-based download: run for specified seconds
downloadTests = [];
const durMs = this.opts.duration * 1000;
const startMs = Date.now();
// use medium chunk size for download
const chunkBytes = 25001000;
while (Date.now() - startMs < durMs) {
const speeds = await measureDownloadParallel(chunkBytes, 1);
downloadTests.push(...speeds);
}
if (downloadTests.length === 0) downloadTests = [0];
} else {
// fixed download segments
const t1 = await measureDownloadParallel(101000, 10);
const t2 = await measureDownloadParallel(1001000, 8);
const t3 = await measureDownloadParallel(10001000, 6);
const t4 = await measureDownloadParallel(25001000, 4);
const t5 = await measureDownloadParallel(100001000, 1);
downloadTests = [...t1, ...t2, ...t3, ...t4, ...t5];
}
const speedDownload = stats.quartile(downloadTests, 0.9).toFixed(2); const speedDownload = stats.quartile(downloadTests, 0.9).toFixed(2);
// lets test the upload speed // lets test the upload speed with configurable parallel streams
const testUp1 = await this.measureUpload(11000, 10); const measureUploadParallel = (bytes: number, iterations: number) => {
const testUp2 = await this.measureUpload(101000, 10); if (parallel <= 1) {
const testUp3 = await this.measureUpload(1001000, 8); return this.measureUpload(bytes, iterations);
const uploadTests = [...testUp1, ...testUp2, ...testUp3]; }
return Promise.all(
Array(parallel)
.fill(null)
.map(() => this.measureUpload(bytes, iterations)),
).then((arrays) => arrays.flat());
};
let uploadTests: number[];
if (this.opts.duration && this.opts.duration > 0) {
// duration-based upload: run for specified seconds
uploadTests = [];
const durMsUp = this.opts.duration * 1000;
const startMsUp = Date.now();
const chunkBytesUp = 1001000;
while (Date.now() - startMsUp < durMsUp) {
const speeds = await measureUploadParallel(chunkBytesUp, 1);
uploadTests.push(...speeds);
}
if (uploadTests.length === 0) uploadTests = [0];
} else {
const u1 = await measureUploadParallel(11000, 10);
const u2 = await measureUploadParallel(101000, 10);
const u3 = await measureUploadParallel(1001000, 8);
uploadTests = [...u1, ...u2, ...u3];
}
const speedUpload = stats.quartile(uploadTests, 0.9).toFixed(2); const speedUpload = stats.quartile(uploadTests, 0.9).toFixed(2);
return { return {
@@ -49,7 +109,7 @@ export class CloudflareSpeed {
measurements.push(response[4] - response[0] - response[6]); measurements.push(response[4] - response[0] - response[6]);
}, },
(error) => { (error) => {
console.log(`Error: ${error}`); getLogger().error('Error measuring latency:', error);
}, },
); );
} }
@@ -73,7 +133,7 @@ export class CloudflareSpeed {
measurements.push(await this.measureSpeed(bytes, transferTime)); measurements.push(await this.measureSpeed(bytes, transferTime));
}, },
(error) => { (error) => {
console.log(`Error: ${error}`); getLogger().error('Error measuring download chunk:', error);
}, },
); );
} }
@@ -91,7 +151,7 @@ export class CloudflareSpeed {
measurements.push(await this.measureSpeed(bytes, transferTime)); measurements.push(await this.measureSpeed(bytes, transferTime));
}, },
(error) => { (error) => {
console.log(`Error: ${error}`); getLogger().error('Error measuring upload chunk:', error);
}, },
); );
} }
@@ -104,15 +164,17 @@ export class CloudflareSpeed {
} }
public async fetchServerLocations(): Promise<{ [key: string]: string }> { public async fetchServerLocations(): Promise<{ [key: string]: string }> {
const res = JSON.parse(await this.get('speed.cloudflare.com', '/locations')); const res = JSON.parse(await this.get('speed.cloudflare.com', '/locations')) as Array<{
iata: string;
return res.reduce((data: any, optionsArg: { iata: string; city: string }) => { city: string;
// Bypass prettier "no-assign-param" rules }>;
const data1 = data; return res.reduce(
(data: Record<string, string>, optionsArg) => {
data1[optionsArg.iata] = optionsArg.city; data[optionsArg.iata] = optionsArg.city;
return data1; return data;
}, {}); },
{} as Record<string, string>,
);
} }
public async get(hostname: string, path: string): Promise<string> { public async get(hostname: string, path: string): Promise<string> {
@@ -122,6 +184,8 @@ export class CloudflareSpeed {
hostname, hostname,
path, path,
method: 'GET', method: 'GET',
// disable connection pooling to avoid listener accumulation
agent: false,
}, },
(res) => { (res) => {
const body: Array<Buffer> = []; const body: Array<Buffer> = [];
@@ -135,8 +199,8 @@ export class CloudflareSpeed {
reject(e); reject(e);
} }
}); });
req.on('error', (err) => { req.on('error', (err: Error & { code?: string }) => {
reject(err); reject(new NetworkError(err.message, err.code));
}); });
}, },
); );
@@ -179,7 +243,9 @@ export class CloudflareSpeed {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
started = plugins.perfHooks.performance.now(); started = plugins.perfHooks.performance.now();
const req = plugins.https.request(options, (res) => { // disable connection pooling to avoid listener accumulation across requests
const reqOptions = { ...options, agent: false };
const req = plugins.https.request(reqOptions, (res) => {
res.once('readable', () => { res.once('readable', () => {
ttfb = plugins.perfHooks.performance.now(); ttfb = plugins.perfHooks.performance.now();
}); });
@@ -193,25 +259,26 @@ export class CloudflareSpeed {
sslHandshake, sslHandshake,
ttfb, ttfb,
ended, ended,
parseFloat(res.headers['server-timing'].slice(22) as any), parseFloat((res.headers['server-timing'] as string).slice(22)),
]); ]);
}); });
}); });
req.on('socket', (socket) => { // Listen for timing events once per new socket
socket.on('lookup', () => { req.once('socket', (socket) => {
socket.once('lookup', () => {
dnsLookup = plugins.perfHooks.performance.now(); dnsLookup = plugins.perfHooks.performance.now();
}); });
socket.on('connect', () => { socket.once('connect', () => {
tcpHandshake = plugins.perfHooks.performance.now(); tcpHandshake = plugins.perfHooks.performance.now();
}); });
socket.on('secureConnect', () => { socket.once('secureConnect', () => {
sslHandshake = plugins.perfHooks.performance.now(); sslHandshake = plugins.perfHooks.performance.now();
}); });
}); });
req.on('error', (error) => { req.on('error', (error: Error & { code?: string }) => {
reject(error); reject(new NetworkError(error.message, error.code));
}); });
req.write(data); req.write(data);
@@ -219,39 +286,25 @@ export class CloudflareSpeed {
}); });
} }
public async fetchCfCdnCgiTrace(): Promise<{ /**
fl: string; * Fetch Cloudflare's trace endpoint and parse key=value lines to a record.
h: string; */
ip: string; public async fetchCfCdnCgiTrace(): Promise<Record<string, string>> {
ts: string;
visit_scheme: string;
uag: string;
colo: string;
http: string;
loc: string;
tls: string;
sni: string;
warp: string;
gateway: string;
}> {
const parseCfCdnCgiTrace = (text: string) => const parseCfCdnCgiTrace = (text: string) =>
text text
.split('\n') .split('\n')
.map((i) => { .map((i) => {
const j = i.split('='); const parts = i.split('=');
return [parts[0], parts[1]];
return [j[0], j[1]];
}) })
.reduce((data: any, [k, v]) => { .reduce(
if (v === undefined) return data; (data: Record<string, string>, [k, v]) => {
if (v === undefined) return data;
// Bypass prettier "no-assign-param" rules data[k] = v;
const data1 = data; return data;
// Object.fromEntries is only supported by Node.js 12 or newer },
data1[k] = v; {} as Record<string, string>,
);
return data1;
}, {});
return this.get('speed.cloudflare.com', '/cdn-cgi/trace').then(parseCfCdnCgiTrace); return this.get('speed.cloudflare.com', '/cdn-cgi/trace').then(parseCfCdnCgiTrace);
} }

View File

@@ -1,28 +1,98 @@
import * as plugins from './smartnetwork.plugins.js'; import * as plugins from './smartnetwork.plugins.js';
import { CloudflareSpeed } from './smartnetwork.classes.cloudflarespeed.js'; import { CloudflareSpeed } from './smartnetwork.classes.cloudflarespeed.js';
import { getLogger } from './logging.js';
import { NetworkError } from './errors.js';
import * as stats from './helpers/stats.js';
/** /**
* SmartNetwork simplifies actions within the network * SmartNetwork simplifies actions within the network
*/ */
/**
* Configuration options for SmartNetwork
*/
export interface SmartNetworkOptions {
/** Cache time-to-live in milliseconds for gateway and public IP lookups */
cacheTtl?: number;
}
/**
* A hop in a traceroute result
*/
export interface Hop {
ttl: number;
ip: string;
rtt: number | null;
}
export class SmartNetwork { export class SmartNetwork {
/** Static registry for external plugins */
public static pluginsRegistry: Map<string, any> = new Map();
/** Register a plugin by name */
public static registerPlugin(name: string, ctor: any): void {
SmartNetwork.pluginsRegistry.set(name, ctor);
}
/** Unregister a plugin by name */
public static unregisterPlugin(name: string): void {
SmartNetwork.pluginsRegistry.delete(name);
}
private options: SmartNetworkOptions;
private cache: Map<string, { value: any; expiry: number }>;
constructor(options?: SmartNetworkOptions) {
this.options = options || {};
this.cache = new Map();
}
/** /**
* get network speed * get network speed
* @param measurementTime * @param opts optional speed test parameters
*/ */
public async getSpeed() { public async getSpeed(opts?: { parallelStreams?: number; duration?: number }) {
const cloudflareSpeedInstance = new CloudflareSpeed(); const cloudflareSpeedInstance = new CloudflareSpeed(opts);
const test = await cloudflareSpeedInstance.speedTest(); return cloudflareSpeedInstance.speedTest();
return test;
} }
public async ping( /**
hostArg: string, * Send ICMP pings to a host. Optionally specify count for multiple pings.
timeoutArg: number = 500, */
): Promise<ReturnType<typeof plugins.smartping.Smartping.prototype.ping>> { public async ping(host: string, opts?: { timeout?: number; count?: number }): Promise<any> {
const smartpingInstance = new plugins.smartping.Smartping(); const timeout = opts?.timeout ?? 500;
const pingResult = await smartpingInstance.ping(hostArg, timeoutArg); const count = opts?.count && opts.count > 1 ? opts.count : 1;
return pingResult; const pinger = new plugins.smartping.Smartping();
if (count === 1) {
// single ping: normalize time to number
const res = await pinger.ping(host, timeout);
return {
...res,
time: typeof res.time === 'number' ? res.time : NaN,
};
}
const times: number[] = [];
let aliveCount = 0;
for (let i = 0; i < count; i++) {
try {
const res = await pinger.ping(host, timeout);
const t = typeof res.time === 'number' ? res.time : NaN;
if (res.alive) aliveCount++;
times.push(t);
} catch {
times.push(NaN);
}
}
const valid = times.filter((t) => !isNaN(t));
const min = valid.length ? Math.min(...valid) : NaN;
const max = valid.length ? Math.max(...valid) : NaN;
const avg = valid.length ? stats.average(valid) : NaN;
const stddev = valid.length ? Math.sqrt(stats.average(valid.map((v) => (v - avg) ** 2))) : NaN;
const packetLoss = ((count - aliveCount) / count) * 100;
return {
host,
count,
times,
min,
max,
avg,
stddev,
packetLoss,
alive: aliveCount > 0,
};
} }
/** /**
@@ -30,6 +100,9 @@ export class SmartNetwork {
* note: false also resolves with false as argument * note: false also resolves with false as argument
* @param port * @param port
*/ */
/**
* Check if a local port is unused (both IPv4 and IPv6)
*/
public async isLocalPortUnused(port: number): Promise<boolean> { public async isLocalPortUnused(port: number): Promise<boolean> {
const doneIpV4 = plugins.smartpromise.defer<boolean>(); const doneIpV4 = plugins.smartpromise.defer<boolean>();
const doneIpV6 = plugins.smartpromise.defer<boolean>(); const doneIpV6 = plugins.smartpromise.defer<boolean>();
@@ -37,11 +110,7 @@ export class SmartNetwork {
// test IPv4 space // test IPv4 space
const ipv4Test = net.createServer(); const ipv4Test = net.createServer();
ipv4Test.once('error', (err: any) => { ipv4Test.once('error', () => {
if (err.code !== 'EADDRINUSE') {
doneIpV4.resolve(false);
return;
}
doneIpV4.resolve(false); doneIpV4.resolve(false);
}); });
ipv4Test.once('listening', () => { ipv4Test.once('listening', () => {
@@ -56,11 +125,7 @@ export class SmartNetwork {
// test IPv6 space // test IPv6 space
const ipv6Test = net.createServer(); const ipv6Test = net.createServer();
ipv6Test.once('error', function (err: any) { ipv6Test.once('error', () => {
if (err.code !== 'EADDRINUSE') {
doneIpV6.resolve(false);
return;
}
doneIpV6.resolve(false); doneIpV6.resolve(false);
}); });
ipv6Test.once('listening', () => { ipv6Test.once('listening', () => {
@@ -78,30 +143,99 @@ export class SmartNetwork {
return result; return result;
} }
/**
* Find the first available port within a given range
* @param startPort The start of the port range (inclusive)
* @param endPort The end of the port range (inclusive)
* @returns The first available port number, or null if no ports are available
*/
public async findFreePort(startPort: number, endPort: number): Promise<number | null> {
// Validate port range
if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) {
throw new NetworkError('Port numbers must be between 1 and 65535', 'EINVAL');
}
if (startPort > endPort) {
throw new NetworkError('Start port must be less than or equal to end port', 'EINVAL');
}
// Check each port in the range
for (let port = startPort; port <= endPort; port++) {
const isUnused = await this.isLocalPortUnused(port);
if (isUnused) {
return port;
}
}
// No free port found in the range
return null;
}
/** /**
* checks wether a remote port is available * checks wether a remote port is available
* @param domainArg * @param domainArg
*/ */
public async isRemotePortAvailable(domainArg: string, portArg?: number): Promise<boolean> { /**
const done = plugins.smartpromise.defer<boolean>(); * Check if a remote port is available
const domainPart = domainArg.split(':')[0]; * @param target host or "host:port"
const port = portArg ? portArg : parseInt(domainArg.split(':')[1], 10); * @param opts options including port, protocol (only tcp), retries and timeout
*/
plugins.isopen(domainPart, port, (response: any) => { /**
console.log(response); * Check if a remote port is available
if (response[port.toString()].isOpen) { * @param target host or "host:port"
done.resolve(true); * @param portOrOpts either a port number (deprecated) or options object
} else { */
done.resolve(false); public async isRemotePortAvailable(
} target: string,
}); portOrOpts?:
const result = await done.promise; | number
return result; | { port?: number; protocol?: 'tcp' | 'udp'; timeout?: number; retries?: number },
): Promise<boolean> {
let hostPart: string;
let port: number | undefined;
let protocol: string = 'tcp';
let retries = 1;
let timeout: number | undefined;
// preserve old signature (target, port)
if (typeof portOrOpts === 'number') {
[hostPart] = target.split(':');
port = portOrOpts;
} else {
const opts = portOrOpts || {};
protocol = opts.protocol ?? 'tcp';
retries = opts.retries ?? 1;
timeout = opts.timeout;
[hostPart] = target.split(':');
const portPart = target.split(':')[1];
port = opts.port ?? (portPart ? parseInt(portPart, 10) : undefined);
}
if (protocol === 'udp') {
throw new NetworkError('UDP port check not supported', 'ENOTSUP');
}
if (!port) {
throw new NetworkError('Port not specified', 'EINVAL');
}
let last: boolean = false;
for (let attempt = 0; attempt < retries; attempt++) {
const done = plugins.smartpromise.defer<boolean>();
plugins.isopen(hostPart, port, (response: Record<string, { isOpen: boolean }>) => {
const info = response[port.toString()];
done.resolve(Boolean(info?.isOpen));
});
last = await done.promise;
if (last) return true;
}
return last;
} }
public async getGateways() { /**
const result = plugins.os.networkInterfaces(); * List network interfaces (gateways)
return result; */
public async getGateways(): Promise<Record<string, plugins.os.NetworkInterfaceInfo[]>> {
const fetcher = async () => plugins.os.networkInterfaces();
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
return this.getCached('gateways', fetcher);
}
return fetcher();
} }
public async getDefaultGateway(): Promise<{ public async getDefaultGateway(): Promise<{
@@ -110,7 +244,7 @@ export class SmartNetwork {
}> { }> {
const defaultGatewayName = await plugins.systeminformation.networkInterfaceDefault(); const defaultGatewayName = await plugins.systeminformation.networkInterfaceDefault();
if (!defaultGatewayName) { if (!defaultGatewayName) {
console.log('Cannot determine default gateway'); getLogger().warn?.('Cannot determine default gateway');
return null; return null;
} }
const gateways = await this.getGateways(); const gateways = await this.getGateways();
@@ -121,24 +255,134 @@ export class SmartNetwork {
}; };
} }
public async getPublicIps() { /**
return { * Lookup public IPv4 and IPv6
v4: await plugins.publicIp */
.publicIpv4({ public async getPublicIps(): Promise<{ v4: string | null; v6: string | null }> {
timeout: 1000, const fetcher = async () => ({
onlyHttps: true, v4: await plugins.publicIp.publicIpv4({ timeout: 1000, onlyHttps: true }).catch(() => null),
}) v6: await plugins.publicIp.publicIpv6({ timeout: 1000, onlyHttps: true }).catch(() => null),
.catch(async (err) => { });
return null; if (this.options.cacheTtl && this.options.cacheTtl > 0) {
}), return this.getCached('publicIps', fetcher);
v6: await plugins.publicIp }
.publicIpv6({ return fetcher();
timeout: 1000, }
onlyHttps: true,
}) /**
.catch(async (err) => { * Resolve DNS records (A, AAAA, MX)
return null; */
}), public async resolveDns(
}; host: string,
): Promise<{ A: string[]; AAAA: string[]; MX: { exchange: string; priority: number }[] }> {
try {
const dns = await import('dns');
const { resolve4, resolve6, resolveMx } = dns.promises;
const [A, AAAA, MX] = await Promise.all([
resolve4(host).catch(() => []),
resolve6(host).catch(() => []),
resolveMx(host).catch(() => []),
]);
return { A, AAAA, MX };
} catch (err: any) {
throw new NetworkError(err.message, err.code);
}
}
/**
* Perform a simple HTTP/HTTPS endpoint health check
*/
public async checkEndpoint(
urlString: string,
opts?: { timeout?: number },
): Promise<{ status: number; headers: Record<string, string>; rtt: number }> {
const start = plugins.perfHooks.performance.now();
try {
const url = new URL(urlString);
const lib = url.protocol === 'https:' ? plugins.https : await import('http');
return new Promise((resolve, reject) => {
const req = lib.request(
url,
{ method: 'GET', timeout: opts?.timeout, agent: false },
(res: any) => {
res.on('data', () => {});
res.once('end', () => {
const rtt = plugins.perfHooks.performance.now() - start;
const headers: Record<string, string> = {};
for (const [k, v] of Object.entries(res.headers)) {
headers[k] = Array.isArray(v) ? v.join(',') : String(v);
}
resolve({ status: res.statusCode, headers, rtt });
});
},
);
req.on('error', (err: any) => reject(new NetworkError(err.message, err.code)));
req.end();
});
} catch (err: any) {
throw new NetworkError(err.message, err.code);
}
}
/**
* Perform a traceroute: hop-by-hop latency using the system traceroute tool.
* Falls back to a single-hop stub if traceroute is unavailable or errors.
*/
public async traceroute(
host: string,
opts?: { maxHops?: number; timeout?: number },
): Promise<Hop[]> {
const maxHops = opts?.maxHops ?? 30;
const timeout = opts?.timeout;
try {
const { exec } = await import('child_process');
const cmd = `traceroute -n -m ${maxHops} ${host}`;
const stdout: string = await new Promise((resolve, reject) => {
exec(cmd, { encoding: 'utf8', timeout }, (err, stdout) => {
if (err) return reject(err);
resolve(stdout);
});
});
const hops: Hop[] = [];
for (const raw of stdout.split('\n')) {
const line = raw.trim();
if (!line || line.startsWith('traceroute')) continue;
const parts = line.split(/\s+/);
const ttl = parseInt(parts[0], 10);
let ip: string;
let rtt: number | null;
if (parts[1] === '*' || !parts[1]) {
ip = parts[1] || '';
rtt = null;
} else {
ip = parts[1];
const timePart = parts.find((p, i) => i >= 2 && /^\d+(\.\d+)?$/.test(p));
rtt = timePart ? parseFloat(timePart) : null;
}
hops.push({ ttl, ip, rtt });
}
if (hops.length) {
return hops;
}
} catch {
// traceroute not available or error: fall through to stub
}
// fallback stub
return [{ ttl: 1, ip: host, rtt: null }];
}
/**
* Internal caching helper
*/
private async getCached<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const now = Date.now();
const entry = this.cache.get(key);
if (entry && entry.expiry > now) {
return entry.value;
}
const value = await fetcher();
const ttl = this.options.cacheTtl || 0;
this.cache.set(key, { value, expiry: now + ttl });
return value;
} }
} }