BREAKING CHANGE(smartnetwork): Enhance documentation and add configurable speed test options with plugin architecture improvements
This commit is contained in:
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartnetwork',
|
||||
version: '3.0.5',
|
||||
version: '4.0.0',
|
||||
description: 'A toolkit for network diagnostics including speed tests, port availability checks, and more.'
|
||||
}
|
||||
|
@ -1 +1,4 @@
|
||||
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';
|
||||
|
@ -3,8 +3,15 @@ import { getLogger } from './logging.js';
|
||||
import { NetworkError, TimeoutError } from './errors.js';
|
||||
import * as stats from './helpers/stats.js';
|
||||
|
||||
export interface SpeedOptions {
|
||||
parallelStreams?: number;
|
||||
duration?: number;
|
||||
}
|
||||
export class CloudflareSpeed {
|
||||
constructor() {}
|
||||
private opts: SpeedOptions;
|
||||
constructor(opts?: SpeedOptions) {
|
||||
this.opts = opts || {};
|
||||
}
|
||||
|
||||
public async speedTest() {
|
||||
const latency = await this.measureLatency();
|
||||
@ -12,20 +19,71 @@ export class CloudflareSpeed {
|
||||
const serverLocations = await this.fetchServerLocations();
|
||||
const cgiData = await this.fetchCfCdnCgiTrace();
|
||||
|
||||
// lets test the download speed
|
||||
const testDown1 = await this.measureDownload(101000, 10);
|
||||
const testDown2 = await this.measureDownload(1001000, 8);
|
||||
const testDown3 = await this.measureDownload(10001000, 6);
|
||||
const testDown4 = await this.measureDownload(25001000, 4);
|
||||
const testDown5 = await this.measureDownload(100001000, 1);
|
||||
const downloadTests = [...testDown1, ...testDown2, ...testDown3, ...testDown4, ...testDown5];
|
||||
// speed tests: either fixed segments or duration-based mode
|
||||
const parallel = this.opts.parallelStreams ?? 1;
|
||||
const measureDownloadParallel = (bytes: number, iterations: number) => {
|
||||
if (parallel <= 1) {
|
||||
return this.measureDownload(bytes, iterations);
|
||||
}
|
||||
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);
|
||||
|
||||
// lets test the upload speed
|
||||
const testUp1 = await this.measureUpload(11000, 10);
|
||||
const testUp2 = await this.measureUpload(101000, 10);
|
||||
const testUp3 = await this.measureUpload(1001000, 8);
|
||||
const uploadTests = [...testUp1, ...testUp2, ...testUp3];
|
||||
// lets test the upload speed with configurable parallel streams
|
||||
const measureUploadParallel = (bytes: number, iterations: number) => {
|
||||
if (parallel <= 1) {
|
||||
return this.measureUpload(bytes, iterations);
|
||||
}
|
||||
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);
|
||||
|
||||
return {
|
||||
@ -218,8 +276,8 @@ export class CloudflareSpeed {
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(error);
|
||||
req.on('error', (error: Error & { code?: string }) => {
|
||||
reject(new NetworkError(error.message, error.code));
|
||||
});
|
||||
|
||||
req.write(data);
|
||||
@ -227,21 +285,10 @@ export class CloudflareSpeed {
|
||||
});
|
||||
}
|
||||
|
||||
public async fetchCfCdnCgiTrace(): Promise<{
|
||||
fl: string;
|
||||
h: string;
|
||||
ip: string;
|
||||
ts: string;
|
||||
visit_scheme: string;
|
||||
uag: string;
|
||||
colo: string;
|
||||
http: string;
|
||||
loc: string;
|
||||
tls: string;
|
||||
sni: string;
|
||||
warp: string;
|
||||
gateway: string;
|
||||
}> {
|
||||
/**
|
||||
* Fetch Cloudflare's trace endpoint and parse key=value lines to a record.
|
||||
*/
|
||||
public async fetchCfCdnCgiTrace(): Promise<Record<string, string>> {
|
||||
const parseCfCdnCgiTrace = (text: string) =>
|
||||
text
|
||||
.split('\n')
|
||||
|
@ -1,29 +1,107 @@
|
||||
import * as plugins from './smartnetwork.plugins.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
|
||||
*/
|
||||
/**
|
||||
* 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 {
|
||||
/** 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
|
||||
* @param measurementTime
|
||||
* @param opts optional speed test parameters
|
||||
*/
|
||||
public async getSpeed() {
|
||||
const cloudflareSpeedInstance = new CloudflareSpeed();
|
||||
const test = await cloudflareSpeedInstance.speedTest();
|
||||
return test;
|
||||
public async getSpeed(
|
||||
opts?: { parallelStreams?: number; duration?: number },
|
||||
) {
|
||||
const cloudflareSpeedInstance = new CloudflareSpeed(opts);
|
||||
return cloudflareSpeedInstance.speedTest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send ICMP pings to a host. Optionally specify count for multiple pings.
|
||||
*/
|
||||
public async ping(
|
||||
hostArg: string,
|
||||
timeoutArg: number = 500,
|
||||
): Promise<ReturnType<typeof plugins.smartping.Smartping.prototype.ping>> {
|
||||
const smartpingInstance = new plugins.smartping.Smartping();
|
||||
const pingResult = await smartpingInstance.ping(hostArg, timeoutArg);
|
||||
return pingResult;
|
||||
host: string,
|
||||
opts?: { timeout?: number; count?: number },
|
||||
): Promise<any> {
|
||||
const timeout = opts?.timeout ?? 500;
|
||||
const count = opts?.count && opts.count > 1 ? opts.count : 1;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -31,6 +109,9 @@ export class SmartNetwork {
|
||||
* note: false also resolves with false as argument
|
||||
* @param port
|
||||
*/
|
||||
/**
|
||||
* Check if a local port is unused (both IPv4 and IPv6)
|
||||
*/
|
||||
public async isLocalPortUnused(port: number): Promise<boolean> {
|
||||
const doneIpV4 = plugins.smartpromise.defer<boolean>();
|
||||
const doneIpV6 = plugins.smartpromise.defer<boolean>();
|
||||
@ -75,27 +156,66 @@ export class SmartNetwork {
|
||||
* checks wether a remote port is available
|
||||
* @param domainArg
|
||||
*/
|
||||
public async isRemotePortAvailable(domainArg: string, portArg?: number): Promise<boolean> {
|
||||
const done = plugins.smartpromise.defer<boolean>();
|
||||
const domainPart = domainArg.split(':')[0];
|
||||
const port = portArg ? portArg : parseInt(domainArg.split(':')[1], 10);
|
||||
|
||||
plugins.isopen(
|
||||
domainPart,
|
||||
port,
|
||||
(response: Record<string, { isOpen: boolean }>) => {
|
||||
getLogger().debug(response);
|
||||
const portInfo = response[port.toString()];
|
||||
done.resolve(Boolean(portInfo?.isOpen));
|
||||
},
|
||||
);
|
||||
const result = await done.promise;
|
||||
return result;
|
||||
/**
|
||||
* Check if a remote port is available
|
||||
* @param target host or "host:port"
|
||||
* @param opts options including port, protocol (only tcp), retries and timeout
|
||||
*/
|
||||
/**
|
||||
* Check if a remote port is available
|
||||
* @param target host or "host:port"
|
||||
* @param portOrOpts either a port number (deprecated) or options object
|
||||
*/
|
||||
public async isRemotePortAvailable(
|
||||
target: string,
|
||||
portOrOpts?: number | { 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();
|
||||
return result;
|
||||
/**
|
||||
* List network interfaces (gateways)
|
||||
*/
|
||||
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<{
|
||||
@ -115,24 +235,136 @@ export class SmartNetwork {
|
||||
};
|
||||
}
|
||||
|
||||
public async getPublicIps() {
|
||||
return {
|
||||
v4: await plugins.publicIp
|
||||
.publicIpv4({
|
||||
timeout: 1000,
|
||||
onlyHttps: true,
|
||||
})
|
||||
.catch(async (err) => {
|
||||
return null;
|
||||
}),
|
||||
v6: await plugins.publicIp
|
||||
.publicIpv6({
|
||||
timeout: 1000,
|
||||
onlyHttps: true,
|
||||
})
|
||||
.catch(async (err) => {
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
/**
|
||||
* Lookup public IPv4 and IPv6
|
||||
*/
|
||||
public async getPublicIps(): Promise<{ v4: string | null; v6: string | null }> {
|
||||
const fetcher = async () => ({
|
||||
v4: await plugins.publicIp.publicIpv4({ timeout: 1000, onlyHttps: true }).catch(() => null),
|
||||
v6: await plugins.publicIp.publicIpv6({ timeout: 1000, onlyHttps: true }).catch(() => null),
|
||||
});
|
||||
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
|
||||
return this.getCached('publicIps', fetcher);
|
||||
}
|
||||
return fetcher();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve DNS records (A, AAAA, MX)
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user