BREAKING CHANGE(smartnetwork): Enhance documentation and add configurable speed test options with plugin architecture improvements
This commit is contained in:
@ -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