@push.rocks/smartnetwork 3.0.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,107 @@
1
1
  import * as plugins from './smartnetwork.plugins.js';
2
-
3
2
  import { CloudflareSpeed } from './smartnetwork.classes.cloudflarespeed.js';
3
+ import { getLogger } from './logging.js';
4
+ import { NetworkError } from './errors.js';
5
+ import * as stats from './helpers/stats.js';
4
6
 
5
7
  /**
6
8
  * SmartNetwork simplifies actions within the network
7
9
  */
10
+ /**
11
+ * Configuration options for SmartNetwork
12
+ */
13
+ export interface SmartNetworkOptions {
14
+ /** Cache time-to-live in milliseconds for gateway and public IP lookups */
15
+ cacheTtl?: number;
16
+ }
17
+
18
+ /**
19
+ * A hop in a traceroute result
20
+ */
21
+ export interface Hop {
22
+ ttl: number;
23
+ ip: string;
24
+ rtt: number | null;
25
+ }
8
26
  export class SmartNetwork {
27
+ /** Static registry for external plugins */
28
+ public static pluginsRegistry: Map<string, any> = new Map();
29
+ /** Register a plugin by name */
30
+ public static registerPlugin(name: string, ctor: any): void {
31
+ SmartNetwork.pluginsRegistry.set(name, ctor);
32
+ }
33
+ /** Unregister a plugin by name */
34
+ public static unregisterPlugin(name: string): void {
35
+ SmartNetwork.pluginsRegistry.delete(name);
36
+ }
37
+ private options: SmartNetworkOptions;
38
+ private cache: Map<string, { value: any; expiry: number }>;
39
+ constructor(options?: SmartNetworkOptions) {
40
+ this.options = options || {};
41
+ this.cache = new Map();
42
+ }
9
43
  /**
10
44
  * get network speed
11
- * @param measurementTime
45
+ * @param opts optional speed test parameters
12
46
  */
13
- public async getSpeed() {
14
- const cloudflareSpeedInstance = new CloudflareSpeed();
15
- const test = await cloudflareSpeedInstance.speedTest();
16
- return test;
47
+ public async getSpeed(
48
+ opts?: { parallelStreams?: number; duration?: number },
49
+ ) {
50
+ const cloudflareSpeedInstance = new CloudflareSpeed(opts);
51
+ return cloudflareSpeedInstance.speedTest();
17
52
  }
18
53
 
19
- public async ping(hostArg: string, timeoutArg: number = 500): Promise<ReturnType<typeof plugins.smartping.Smartping.prototype.ping>> {
20
- const smartpingInstance = new plugins.smartping.Smartping();
21
- const pingResult = await smartpingInstance.ping(hostArg, timeoutArg);
22
- return pingResult;
54
+ /**
55
+ * Send ICMP pings to a host. Optionally specify count for multiple pings.
56
+ */
57
+ public async ping(
58
+ host: string,
59
+ opts?: { timeout?: number; count?: number },
60
+ ): Promise<any> {
61
+ const timeout = opts?.timeout ?? 500;
62
+ const count = opts?.count && opts.count > 1 ? opts.count : 1;
63
+ const pinger = new plugins.smartping.Smartping();
64
+ if (count === 1) {
65
+ // single ping: normalize time to number
66
+ const res = await pinger.ping(host, timeout);
67
+ return {
68
+ ...res,
69
+ time: typeof res.time === 'number' ? res.time : NaN,
70
+ };
71
+ }
72
+ const times: number[] = [];
73
+ let aliveCount = 0;
74
+ for (let i = 0; i < count; i++) {
75
+ try {
76
+ const res = await pinger.ping(host, timeout);
77
+ const t = typeof res.time === 'number' ? res.time : NaN;
78
+ if (res.alive) aliveCount++;
79
+ times.push(t);
80
+ } catch {
81
+ times.push(NaN);
82
+ }
83
+ }
84
+ const valid = times.filter((t) => !isNaN(t));
85
+ const min = valid.length ? Math.min(...valid) : NaN;
86
+ const max = valid.length ? Math.max(...valid) : NaN;
87
+ const avg = valid.length ? stats.average(valid) : NaN;
88
+ const stddev = valid.length
89
+ ? Math.sqrt(
90
+ stats.average(valid.map((v) => (v - avg) ** 2)),
91
+ )
92
+ : NaN;
93
+ const packetLoss = ((count - aliveCount) / count) * 100;
94
+ return {
95
+ host,
96
+ count,
97
+ times,
98
+ min,
99
+ max,
100
+ avg,
101
+ stddev,
102
+ packetLoss,
103
+ alive: aliveCount > 0,
104
+ };
23
105
  }
24
106
 
25
107
  /**
@@ -27,6 +109,9 @@ export class SmartNetwork {
27
109
  * note: false also resolves with false as argument
28
110
  * @param port
29
111
  */
112
+ /**
113
+ * Check if a local port is unused (both IPv4 and IPv6)
114
+ */
30
115
  public async isLocalPortUnused(port: number): Promise<boolean> {
31
116
  const doneIpV4 = plugins.smartpromise.defer<boolean>();
32
117
  const doneIpV6 = plugins.smartpromise.defer<boolean>();
@@ -34,11 +119,7 @@ export class SmartNetwork {
34
119
 
35
120
  // test IPv4 space
36
121
  const ipv4Test = net.createServer();
37
- ipv4Test.once('error', (err: any) => {
38
- if (err.code !== 'EADDRINUSE') {
39
- doneIpV4.resolve(false);
40
- return;
41
- }
122
+ ipv4Test.once('error', () => {
42
123
  doneIpV4.resolve(false);
43
124
  });
44
125
  ipv4Test.once('listening', () => {
@@ -53,11 +134,7 @@ export class SmartNetwork {
53
134
 
54
135
  // test IPv6 space
55
136
  const ipv6Test = net.createServer();
56
- ipv6Test.once('error', function (err: any) {
57
- if (err.code !== 'EADDRINUSE') {
58
- doneIpV6.resolve(false);
59
- return;
60
- }
137
+ ipv6Test.once('error', () => {
61
138
  doneIpV6.resolve(false);
62
139
  });
63
140
  ipv6Test.once('listening', () => {
@@ -79,26 +156,66 @@ export class SmartNetwork {
79
156
  * checks wether a remote port is available
80
157
  * @param domainArg
81
158
  */
82
- public async isRemotePortAvailable(domainArg: string, portArg?: number): Promise<boolean> {
83
- const done = plugins.smartpromise.defer<boolean>();
84
- const domainPart = domainArg.split(':')[0];
85
- const port = portArg ? portArg : parseInt(domainArg.split(':')[1], 10);
86
-
87
- plugins.isopen(domainPart, port, (response: any) => {
88
- console.log(response);
89
- if (response[port.toString()].isOpen) {
90
- done.resolve(true);
91
- } else {
92
- done.resolve(false);
93
- }
94
- });
95
- const result = await done.promise;
96
- return result;
159
+ /**
160
+ * Check if a remote port is available
161
+ * @param target host or "host:port"
162
+ * @param opts options including port, protocol (only tcp), retries and timeout
163
+ */
164
+ /**
165
+ * Check if a remote port is available
166
+ * @param target host or "host:port"
167
+ * @param portOrOpts either a port number (deprecated) or options object
168
+ */
169
+ public async isRemotePortAvailable(
170
+ target: string,
171
+ portOrOpts?: number | { port?: number; protocol?: 'tcp' | 'udp'; timeout?: number; retries?: number },
172
+ ): Promise<boolean> {
173
+ let hostPart: string;
174
+ let port: number | undefined;
175
+ let protocol: string = 'tcp';
176
+ let retries = 1;
177
+ let timeout: number | undefined;
178
+ // preserve old signature (target, port)
179
+ if (typeof portOrOpts === 'number') {
180
+ [hostPart] = target.split(':');
181
+ port = portOrOpts;
182
+ } else {
183
+ const opts = portOrOpts || {};
184
+ protocol = opts.protocol ?? 'tcp';
185
+ retries = opts.retries ?? 1;
186
+ timeout = opts.timeout;
187
+ [hostPart] = target.split(':');
188
+ const portPart = target.split(':')[1];
189
+ port = opts.port ?? (portPart ? parseInt(portPart, 10) : undefined);
190
+ }
191
+ if (protocol === 'udp') {
192
+ throw new NetworkError('UDP port check not supported', 'ENOTSUP');
193
+ }
194
+ if (!port) {
195
+ throw new NetworkError('Port not specified', 'EINVAL');
196
+ }
197
+ let last: boolean = false;
198
+ for (let attempt = 0; attempt < retries; attempt++) {
199
+ const done = plugins.smartpromise.defer<boolean>();
200
+ plugins.isopen(hostPart, port, (response: Record<string, { isOpen: boolean }>) => {
201
+ const info = response[port.toString()];
202
+ done.resolve(Boolean(info?.isOpen));
203
+ });
204
+ last = await done.promise;
205
+ if (last) return true;
206
+ }
207
+ return last;
97
208
  }
98
209
 
99
- public async getGateways() {
100
- const result = plugins.os.networkInterfaces();
101
- return result;
210
+ /**
211
+ * List network interfaces (gateways)
212
+ */
213
+ public async getGateways(): Promise<Record<string, plugins.os.NetworkInterfaceInfo[]>> {
214
+ const fetcher = async () => plugins.os.networkInterfaces();
215
+ if (this.options.cacheTtl && this.options.cacheTtl > 0) {
216
+ return this.getCached('gateways', fetcher);
217
+ }
218
+ return fetcher();
102
219
  }
103
220
 
104
221
  public async getDefaultGateway(): Promise<{
@@ -107,7 +224,7 @@ export class SmartNetwork {
107
224
  }> {
108
225
  const defaultGatewayName = await plugins.systeminformation.networkInterfaceDefault();
109
226
  if (!defaultGatewayName) {
110
- console.log('Cannot determine default gateway');
227
+ getLogger().warn?.('Cannot determine default gateway');
111
228
  return null;
112
229
  }
113
230
  const gateways = await this.getGateways();
@@ -118,20 +235,136 @@ export class SmartNetwork {
118
235
  };
119
236
  }
120
237
 
121
- public async getPublicIps() {
122
- return {
123
- v4: await plugins.publicIp.publicIpv4({
124
- timeout: 1000,
125
- onlyHttps: true,
126
- }).catch(async (err) => {
127
- return null
128
- }),
129
- v6: await plugins.publicIp.publicIpv6({
130
- timeout: 1000,
131
- onlyHttps: true,
132
- }).catch(async (err) => {
133
- return null
134
- })
135
- };
238
+ /**
239
+ * Lookup public IPv4 and IPv6
240
+ */
241
+ public async getPublicIps(): Promise<{ v4: string | null; v6: string | null }> {
242
+ const fetcher = async () => ({
243
+ v4: await plugins.publicIp.publicIpv4({ timeout: 1000, onlyHttps: true }).catch(() => null),
244
+ v6: await plugins.publicIp.publicIpv6({ timeout: 1000, onlyHttps: true }).catch(() => null),
245
+ });
246
+ if (this.options.cacheTtl && this.options.cacheTtl > 0) {
247
+ return this.getCached('publicIps', fetcher);
248
+ }
249
+ return fetcher();
250
+ }
251
+
252
+ /**
253
+ * Resolve DNS records (A, AAAA, MX)
254
+ */
255
+ public async resolveDns(host: string): Promise<{ A: string[]; AAAA: string[]; MX: { exchange: string; priority: number }[] }> {
256
+ try {
257
+ const dns = await import('dns');
258
+ const { resolve4, resolve6, resolveMx } = dns.promises;
259
+ const [A, AAAA, MX] = await Promise.all([
260
+ resolve4(host).catch(() => []),
261
+ resolve6(host).catch(() => []),
262
+ resolveMx(host).catch(() => []),
263
+ ]);
264
+ return { A, AAAA, MX };
265
+ } catch (err: any) {
266
+ throw new NetworkError(err.message, err.code);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Perform a simple HTTP/HTTPS endpoint health check
272
+ */
273
+ public async checkEndpoint(
274
+ urlString: string,
275
+ opts?: { timeout?: number },
276
+ ): Promise<{ status: number; headers: Record<string, string>; rtt: number }> {
277
+ const start = plugins.perfHooks.performance.now();
278
+ try {
279
+ const url = new URL(urlString);
280
+ const lib = url.protocol === 'https:' ? plugins.https : await import('http');
281
+ return new Promise((resolve, reject) => {
282
+ const req = lib.request(
283
+ url,
284
+ { method: 'GET', timeout: opts?.timeout, agent: false },
285
+ (res: any) => {
286
+ res.on('data', () => {});
287
+ res.once('end', () => {
288
+ const rtt = plugins.perfHooks.performance.now() - start;
289
+ const headers: Record<string, string> = {};
290
+ for (const [k, v] of Object.entries(res.headers)) {
291
+ headers[k] = Array.isArray(v) ? v.join(',') : String(v);
292
+ }
293
+ resolve({ status: res.statusCode, headers, rtt });
294
+ });
295
+ },
296
+ );
297
+ req.on('error', (err: any) => reject(new NetworkError(err.message, err.code)));
298
+ req.end();
299
+ });
300
+ } catch (err: any) {
301
+ throw new NetworkError(err.message, err.code);
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Perform a traceroute: hop-by-hop latency using the system traceroute tool.
307
+ * Falls back to a single-hop stub if traceroute is unavailable or errors.
308
+ */
309
+ public async traceroute(
310
+ host: string,
311
+ opts?: { maxHops?: number; timeout?: number },
312
+ ): Promise<Hop[]> {
313
+ const maxHops = opts?.maxHops ?? 30;
314
+ const timeout = opts?.timeout;
315
+ try {
316
+ const { exec } = await import('child_process');
317
+ const cmd = `traceroute -n -m ${maxHops} ${host}`;
318
+ const stdout: string = await new Promise((resolve, reject) => {
319
+ exec(
320
+ cmd,
321
+ { encoding: 'utf8', timeout },
322
+ (err, stdout) => {
323
+ if (err) return reject(err);
324
+ resolve(stdout);
325
+ },
326
+ );
327
+ });
328
+ const hops: Hop[] = [];
329
+ for (const raw of stdout.split('\n')) {
330
+ const line = raw.trim();
331
+ if (!line || line.startsWith('traceroute')) continue;
332
+ const parts = line.split(/\s+/);
333
+ const ttl = parseInt(parts[0], 10);
334
+ let ip: string;
335
+ let rtt: number | null;
336
+ if (parts[1] === '*' || !parts[1]) {
337
+ ip = parts[1] || '';
338
+ rtt = null;
339
+ } else {
340
+ ip = parts[1];
341
+ const timePart = parts.find((p, i) => i >= 2 && /^\d+(\.\d+)?$/.test(p));
342
+ rtt = timePart ? parseFloat(timePart) : null;
343
+ }
344
+ hops.push({ ttl, ip, rtt });
345
+ }
346
+ if (hops.length) {
347
+ return hops;
348
+ }
349
+ } catch {
350
+ // traceroute not available or error: fall through to stub
351
+ }
352
+ // fallback stub
353
+ return [{ ttl: 1, ip: host, rtt: null }];
354
+ }
355
+
356
+ /**
357
+ * Internal caching helper
358
+ */
359
+ private async getCached<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
360
+ const now = Date.now();
361
+ const entry = this.cache.get(key);
362
+ if (entry && entry.expiry > now) {
363
+ return entry.value;
364
+ }
365
+ const value = await fetcher();
366
+ const ttl = this.options.cacheTtl || 0;
367
+ this.cache.set(key, { value, expiry: now + ttl });
368
+ return value;
136
369
  }
137
370
  }
@@ -6,9 +6,9 @@ import * as perfHooks from 'perf_hooks';
6
6
  export { os, https, perfHooks };
7
7
 
8
8
  // @pushrocks scope
9
- import * as smartping from '@pushrocks/smartping';
10
- import * as smartpromise from '@pushrocks/smartpromise';
11
- import * as smartstring from '@pushrocks/smartstring';
9
+ import * as smartping from '@push.rocks/smartping';
10
+ import * as smartpromise from '@push.rocks/smartpromise';
11
+ import * as smartstring from '@push.rocks/smartstring';
12
12
 
13
13
  export { smartpromise, smartping, smartstring };
14
14