@push.rocks/smartnetwork 3.0.2 → 4.0.1

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