pyre-cli 1.4.0 → 2.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.
package/src/live/state.ts CHANGED
@@ -27,16 +27,19 @@ const state = {
27
27
 
28
28
  exportDir: './pyre-exports',
29
29
  currentTheme: 'default' as ThemeName,
30
- visiblePanels: {
31
- cpu: true,
32
- mem: true,
33
- power: true,
34
- battery: true,
35
- thermal: true,
36
- network: true,
37
- disk: true,
38
- process: true,
39
- } as VisibleItems,
30
+ visiblePanels: {
31
+ cpu: true,
32
+ mem: true,
33
+ gpu: true,
34
+ power: true,
35
+ battery: true,
36
+ thermal: true,
37
+ network: true,
38
+ packets: true,
39
+ tasks: true,
40
+ disk: true,
41
+ process: true,
42
+ } as VisibleItems,
40
43
  logging: false,
41
44
  logStream: null as fs.WriteStream | null,
42
45
  statusMessage: '',
@@ -57,20 +60,37 @@ const state = {
57
60
  'Theme',
58
61
  'Graph Mode',
59
62
  'Toggle CPU',
60
-
61
- 'Toggle Memory',
62
- 'Toggle Power',
63
- 'Toggle Battery',
64
- 'Toggle Thermal',
65
- 'Toggle Network',
66
- 'Toggle Disk',
67
- 'Toggle Processes',
68
- 'Toggle Tree View',
69
- ],
63
+ 'Toggle Memory',
64
+ 'Toggle GPU',
65
+ 'Toggle Power',
66
+ 'Toggle Battery',
67
+ 'Toggle Thermal',
68
+ 'Toggle Network',
69
+ 'Toggle Packets',
70
+ 'Toggle Tasks',
71
+ 'Toggle Disk',
72
+ 'Toggle Processes',
73
+ 'Toggle Tree View',
74
+ ],
70
75
  CPU_ALERT_PCT: 90,
71
76
  TEMP_ALERT_C: 95,
72
77
  alerted: false,
73
- };
78
+ activePanel: 'grid' as 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process',
79
+ mouseEnabled: true,
80
+ PANEL_TABS: [
81
+ { id: 'cpu', label: 'CPU', key: '1' },
82
+ { id: 'mem', label: 'Memory', key: '2' },
83
+ { id: 'gpu', label: 'GPU', key: '3' },
84
+ { id: 'power', label: 'Power', key: '4' },
85
+ { id: 'battery', label: 'Battery', key: '5' },
86
+ { id: 'thermal', label: 'Thermal', key: '6' },
87
+ { id: 'network', label: 'Network', key: '7' },
88
+ { id: 'packets', label: 'Packets', key: '8' },
89
+ { id: 'tasks', label: 'Tasks', key: '9' },
90
+ { id: 'disk', label: 'Disk', key: '0' },
91
+ { id: 'process', label: 'Process', key: 'P' },
92
+ ] as const,
93
+ };
74
94
 
75
95
  function setStatus(msg: string, ms = 3000) {
76
96
  state.statusMessage = msg;
@@ -14,14 +14,71 @@
14
14
  import chalk from 'chalk';
15
15
  import { run } from './run.js';
16
16
  import { getSmcMetrics, parseSuffix } from './smc.js';
17
- import type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerData, DiskData, NetworkData, ProcessData, GpuData } from './types.js';
17
+ import type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerData, DiskData, NetworkData, ProcessData, GpuData, PacketData, NetworkProcess } from './types.js';
18
+
19
+ // Retain samples over a *time* window rather than a fixed tick count. At the
20
+ // default 2s refresh interval the old 10-sample window only spanned ~20s,
21
+ // but battery level only moves in whole-percent steps that typically take
22
+ // several minutes to change — so `discharged` was almost always 0 and the
23
+ // estimate never populated. A 15-minute time window plus a minimum elapsed
24
+ // time before we trust the numbers gives enough resolution to see a real
25
+ // drop while still updating reasonably often.
26
+ const BATTERY_WINDOW_MS = 15 * 60 * 1000;
27
+ const MIN_ESTIMATE_WINDOW_MS = 30 * 1000;
28
+
29
+ let prevBattery: { samples: { level: number; ts: number }[] } = { samples: [] };
30
+
31
+ function estimateBatteryLife(level: number, state: string): { estimatedTimeToEmpty?: string; dischargeRatePerHour?: number; powerWatts?: number } {
32
+ const now = Date.now();
33
+
34
+ if (state === 'charged' || state === 'charging') {
35
+ // Reset the window on a state change so a stale pre-charge sample
36
+ // doesn't get blended with post-charge readings once discharging
37
+ // resumes.
38
+ prevBattery.samples = [];
39
+ return {};
40
+ }
41
+
42
+ prevBattery.samples.push({ level, ts: now });
43
+ prevBattery.samples = prevBattery.samples.filter(s => now - s.ts <= BATTERY_WINDOW_MS);
44
+
45
+ const first = prevBattery.samples[0];
46
+ const elapsedMs = now - first.ts;
47
+ if (elapsedMs < MIN_ESTIMATE_WINDOW_MS) {
48
+ return {};
49
+ }
50
+
51
+ const dtHours = Math.max(elapsedMs / 1000 / 3600, 0.0001);
52
+ const discharged = first.level - level;
53
+ if (discharged <= 0) {
54
+ // No measurable drop yet within the window — report "calculating"
55
+ // rather than silently omitting the field, so the UI shows progress
56
+ // instead of a blank card.
57
+ return { estimatedTimeToEmpty: 'calculating' };
58
+ }
59
+
60
+ const dischargeRate = discharged / dtHours; // %/hour
61
+ const remainingLevel = Math.max(level, 0);
62
+ const hoursToEmpty = remainingLevel / dischargeRate;
63
+ const h = Math.floor(hoursToEmpty);
64
+ const m = Math.round((hoursToEmpty - h) * 60);
65
+ const estimatedTimeToEmpty = hoursToEmpty > 0 ? `${h}h ${m}m` : 'calculating';
66
+
67
+ // Rough wattage from discharge rate: %/hour * (typical battery capacity
68
+ // in Wh) / 100. This is only used as a last-resort estimate — collectPower()
69
+ // now prefers real ioreg-derived wattage when available (see below).
70
+ const ASSUMED_CAPACITY_WH = 55;
71
+ const powerWatts = Math.round(dischargeRate * (ASSUMED_CAPACITY_WH / 100) * 100) / 100;
72
+
73
+ return { estimatedTimeToEmpty, dischargeRatePerHour: Math.round(dischargeRate * 10) / 10, powerWatts };
74
+ }
18
75
 
19
76
  /**
20
77
  * Gather every metric category in parallel and return a
21
78
  * complete {@link StatsData} snapshot.
22
79
  */
23
80
  export async function collectAll(opts: { detailed?: boolean; processLimit?: number } = {}): Promise<StatsData> {
24
- const [cpu, gpu, memory, disk, battery, thermal, network, processes, system, power] = await Promise.all([
81
+ const [cpu, gpu, memory, disk, battery, thermal, network, processes, system, power, packets, tasks] = await Promise.all([
25
82
  collectCpu(),
26
83
  collectGpu(opts.detailed),
27
84
  collectMemory(),
@@ -29,9 +86,11 @@ export async function collectAll(opts: { detailed?: boolean; processLimit?: numb
29
86
  collectBattery(),
30
87
  collectThermal(opts.detailed),
31
88
  collectNetwork(),
32
- collectProcesses(opts.processLimit ?? 10),
89
+ collectProcesses(opts.processLimit),
33
90
  collectSystem(),
34
91
  collectPower(),
92
+ collectPackets(),
93
+ collectTasks(),
35
94
  ]);
36
95
 
37
96
  return {
@@ -51,6 +110,8 @@ export async function collectAll(opts: { detailed?: boolean; processLimit?: numb
51
110
  processes,
52
111
  power,
53
112
  timestamp: new Date().toISOString(),
113
+ packets,
114
+ tasks,
54
115
  };
55
116
  }
56
117
 
@@ -62,11 +123,54 @@ export async function collectAll(opts: { detailed?: boolean; processLimit?: numb
62
123
  export async function collectPower(): Promise<PowerData | null> {
63
124
  try {
64
125
  const { power } = await getSmcMetrics();
65
- if (power.cpu === undefined && power.gpu === undefined && power.combined === undefined) return null;
66
- return { cpuWatts: power.cpu, gpuWatts: power.gpu, combinedWatts: power.combined };
126
+ if (power.cpu !== undefined || power.gpu !== undefined || power.combined !== undefined) {
127
+ return { cpuWatts: power.cpu, gpuWatts: power.gpu, combinedWatts: power.combined };
128
+ }
67
129
  } catch {
68
- return null;
130
+ // ignore
69
131
  }
132
+
133
+ // `pmset -g batt` never actually prints a "mW" figure on any macOS
134
+ // version — that regex could never match, so this fallback was dead
135
+ // code and collectPower() silently returned null whenever powermetrics
136
+ // wasn't available (which is most machines without passwordless sudo
137
+ // configured). ioreg's AppleSmartBattery entry exposes instantaneous
138
+ // amperage (mA, signed: negative while discharging) and voltage (mV)
139
+ // without any privileges, so we can derive real wattage from that.
140
+ try {
141
+ const ioreg = (await run('ioreg -rn AppleSmartBattery 2>/dev/null')).trim();
142
+ const ampMatch = ioreg.match(/"InstantAmperage"\s*=\s*(-?\d+)/);
143
+ const voltMatch = ioreg.match(/"Voltage"\s*=\s*(\d+)/);
144
+ if (ampMatch && voltMatch) {
145
+ // Two representations show up in the wild: Intel Macs often print a
146
+ // literal minus sign ("-1200") — no unwrap needed. Apple Silicon
147
+ // instead wraps a negative reading into an *unsigned 64-bit* field
148
+ // (e.g. real -1200mA prints as 18446744073709550416). The previous
149
+ // 32-bit unwrap (`- 0x100000000`) didn't touch that at all, and
150
+ // `parseInt` can't even represent a number that large precisely, so
151
+ // it silently mangled into a huge garbage value → absurd wattage.
152
+ // Parse as BigInt and only unwrap when there's no explicit sign.
153
+ let ampsBig = BigInt(ampMatch[1]);
154
+ if (!ampMatch[1].startsWith('-')) {
155
+ const UINT64_MAX_PLUS_1 = 1n << 64n;
156
+ const INT64_MAX = (1n << 63n) - 1n;
157
+ if (ampsBig > INT64_MAX) ampsBig -= UINT64_MAX_PLUS_1;
158
+ }
159
+ const amps = Number(ampsBig);
160
+ const volts = parseInt(voltMatch[1], 10) / 1000;
161
+ const watts = Math.abs((amps / 1000) * volts);
162
+ // Sanity clamp — a real Mac never draws anywhere near this much;
163
+ // treat an out-of-range reading as "no usable data" rather than
164
+ // display nonsense.
165
+ if (Number.isFinite(watts) && watts < 500) {
166
+ return { combinedWatts: Math.round(watts * 100) / 100 };
167
+ }
168
+ }
169
+ } catch {
170
+ // ignore
171
+ }
172
+
173
+ return null;
70
174
  }
71
175
 
72
176
  /**
@@ -274,7 +378,7 @@ export async function collectBattery(): Promise<BatteryData | null> {
274
378
 
275
379
  const levelMatch = raw.match(/(\d+)%/);
276
380
  const stateMatch = raw.match(/;\s*(charging|discharging|charged|finishing charge|attached to charger)/i);
277
- const timeMatch = raw.match(/:\s*([\d:]+)\s+remaining/i);
381
+ const timeMatch = raw.match(/:\s*([\d:]+)\s+remaining/i) || raw.match(/(\d+:\d+)\s+remaining/i);
278
382
  const powerPlugged = raw.includes('AC Power') || raw.includes('attached to charger');
279
383
 
280
384
  const level = levelMatch ? parseInt(levelMatch[1]) : 0;
@@ -286,10 +390,6 @@ export async function collectBattery(): Promise<BatteryData | null> {
286
390
  let maxCapacityPercent: number | undefined;
287
391
 
288
392
  try {
289
- // Plain text output (NOT -json) — its field names ("Cycle Count:", "Condition:")
290
- // are what the regexes below expect. Using -json here was the original bug:
291
- // it silently returned JSON that these text regexes could never match, so
292
- // health always fell back to 'unknown'.
293
393
  const battInfo = (await run('system_profiler SPPowerDataType 2>/dev/null')).trim();
294
394
 
295
395
  const cycleMatch = battInfo.match(/Cycle Count:\s+(\d+)/);
@@ -302,8 +402,6 @@ export async function collectBattery(): Promise<BatteryData | null> {
302
402
  if (maxCapMatch) {
303
403
  maxCapacityPercent = parseInt(maxCapMatch[1]);
304
404
  } else {
305
- // Older/alternate output reports Full Charge Capacity vs Design Capacity
306
- // instead of a direct percentage — derive it if both are present.
307
405
  const fullMatch = battInfo.match(/Full Charge Capacity[^\d]*(\d+)/i);
308
406
  const designMatch = battInfo.match(/Design Capacity[^\d]*(\d+)/i);
309
407
  if (fullMatch && designMatch) {
@@ -322,6 +420,8 @@ export async function collectBattery(): Promise<BatteryData | null> {
322
420
  if (cycles !== undefined) healthParts.push(`${cycles} cycles`);
323
421
  const health = healthParts.length ? healthParts.join(', ') : 'unknown';
324
422
 
423
+ const estimate = estimateBatteryLife(level, state);
424
+
325
425
  return {
326
426
  level,
327
427
  state,
@@ -331,6 +431,7 @@ export async function collectBattery(): Promise<BatteryData | null> {
331
431
  cycles,
332
432
  condition,
333
433
  maxCapacityPercent,
434
+ ...estimate,
334
435
  };
335
436
  } catch {
336
437
  return null;
@@ -466,9 +567,13 @@ export async function collectNetwork(): Promise<NetworkData> {
466
567
  }
467
568
  }
468
569
 
469
- export async function collectProcesses(limit = 10): Promise<ProcessData[]> {
570
+ export async function collectProcesses(limit?: number): Promise<ProcessData[]> {
470
571
  try {
471
- const raw = (await run(`ps -Ao pid,ppid,user,pcpu,pmem,state,threads,time,comm -r | head -n ${limit + 1}`)).trim();
572
+ const isMac = process.platform === 'darwin';
573
+ const sortArg = isMac ? '-r' : '--sort=-pcpu';
574
+ const threadsArg = isMac ? 'threads' : 'nlwp';
575
+ const headClause = limit && limit > 0 ? ` | head -n ${limit + 1}` : '';
576
+ const raw = (await run(`ps -eo pid,ppid,user,pcpu,pmem,state,${threadsArg},time,comm ${sortArg}${headClause}`)).trim();
472
577
  const lines = raw.split('\n').slice(1);
473
578
  return lines
474
579
  .map(line => {
@@ -478,7 +583,105 @@ export async function collectProcesses(limit = 10): Promise<ProcessData[]> {
478
583
  return { pid: parseInt(parts[1]), ppid: parseInt(parts[2]), user: parts[3], cpu: parseFloat(parts[4]), mem: parseFloat(parts[5]), state: parts[6], threads: parseInt(parts[7]), runtime: runtimeSec, command: parts[9] };
479
584
  })
480
585
  .filter((p): p is ProcessData => p !== null && p.pid > 0);
586
+ } catch {
587
+ return [];
588
+ }
589
+ }
590
+
591
+ export async function collectPackets(): Promise<PacketData | null> {
592
+ try {
593
+ const netStat = (await run('netstat -ib')).trim();
594
+ const lines = netStat.split('\n').slice(1);
595
+ let totalRxPackets = 0;
596
+ let totalTxPackets = 0;
597
+ let totalRxBytes = 0;
598
+ let totalTxBytes = 0;
599
+ const ifaceStats: { iface: string; rxPackets: number; txPackets: number; rxBytes: number; txBytes: number }[] = [];
600
+
601
+ for (const line of lines) {
602
+ const parts = line.split(/\s+/);
603
+ if (parts.length >= 11 && parts[0] && !parts[0].startsWith('Name')) {
604
+ const iface = parts[0];
605
+ const rxPackets = parseInt(parts[4]) || 0;
606
+ const rxBytes = parseInt(parts[5]) || 0;
607
+ const txPackets = parseInt(parts[6]) || 0;
608
+ const txBytes = parseInt(parts[7]) || 0;
609
+ totalRxPackets += rxPackets;
610
+ totalTxPackets += txPackets;
611
+ totalRxBytes += rxBytes;
612
+ totalTxBytes += txBytes;
613
+ if (iface && !iface.includes('lo')) {
614
+ ifaceStats.push({ iface, rxPackets, txPackets, rxBytes, txBytes });
615
+ }
616
+ }
617
+ }
618
+
619
+ let connections = 0;
620
+ try {
621
+ const tcpConns = (await run('netstat -an | grep ESTABLISHED | wc -l', '0')).trim();
622
+ connections = parseInt(tcpConns) || 0;
623
+ } catch {
624
+ connections = 0;
625
+ }
626
+
627
+ let topProcesses: { pid: number; command: string; rxBytes: number; txBytes: number }[] = [];
628
+ let allProcesses: { pid: number; command: string; rxBytes: number; txBytes: number; rxPackets?: number; txPackets?: number }[] = [];
629
+ try {
630
+ const lsof = (await run('lsof -i -n -P -r 1 2>/dev/null | tail -n +2 | awk \'{print $1, $2}\' | sort | uniq -c | sort -rn', '')).trim();
631
+ const lsofLines = lsof.split('\n');
632
+ topProcesses = lsofLines.map(line => {
633
+ const parts = line.trim().split(/\s+/);
634
+ if (parts.length >= 3) {
635
+ return { pid: parseInt(parts[2]) || 0, command: parts[1], rxBytes: 0, txBytes: parseInt(parts[0]) || 0 };
636
+ }
637
+ return { pid: 0, command: '', rxBytes: 0, txBytes: 0 };
638
+ }).filter(p => p.pid > 0);
639
+
640
+ const allLsof = (await run('lsof -i -n -P 2>/dev/null | tail -n +2 | awk \'{print $1, $2}\' | sort | uniq -c | sort -rn', '')).trim();
641
+ const allLsofLines = allLsof.split('\n');
642
+ allProcesses = allLsofLines.map(line => {
643
+ const parts = line.trim().split(/\s+/);
644
+ if (parts.length >= 3) {
645
+ return { pid: parseInt(parts[2]) || 0, command: parts[1], rxBytes: 0, txBytes: parseInt(parts[0]) || 0 };
646
+ }
647
+ return { pid: 0, command: '', rxBytes: 0, txBytes: 0 };
648
+ }).filter(p => p.pid > 0);
649
+ } catch {
650
+ topProcesses = [];
651
+ allProcesses = [];
652
+ }
653
+
654
+ return {
655
+ totalPackets: totalRxPackets + totalTxPackets,
656
+ rxPackets: totalRxPackets,
657
+ txPackets: totalTxPackets,
658
+ rxRate: 0,
659
+ txRate: 0,
660
+ connections,
661
+ topProcesses,
662
+ interfaces: ifaceStats,
663
+ allProcesses,
664
+ };
481
665
  } catch {
482
- return [];
666
+ return null;
483
667
  }
484
- }
668
+ }
669
+
670
+ export async function collectTasks(limit = 12): Promise<TaskData[]> {
671
+ try {
672
+ const isMac = process.platform === 'darwin';
673
+ const sortArg = isMac ? '-r' : '--sort=-pcpu';
674
+ const raw = (await run(`ps -eo pid,user,pcpu,pmem,state,time,comm ${sortArg} | head -n ${limit + 1}`)).trim();
675
+ const lines = raw.split('\n').slice(1);
676
+ return lines
677
+ .map(line => {
678
+ const parts = line.match(/\s*(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+([\d:]+)\s+(.+)/);
679
+ if (!parts) return null;
680
+ const runtimeSec = parts[6].split(':').reduce((acc, val, idx) => acc + parseInt(val) * Math.pow(60, 2 - idx), 0);
681
+ return { pid: parseInt(parts[1]), user: parts[2], cpu: parseFloat(parts[3]), mem: parseFloat(parts[4]), state: parts[5], runtime: runtimeSec, command: parts[7] };
682
+ })
683
+ .filter((t): t is TaskData => t !== null && t.pid > 0);
684
+ } catch {
685
+ return [];
686
+ }
687
+ }
@@ -10,5 +10,5 @@
10
10
  * - `collectors.ts` — individual metric collectors and the orchestrator
11
11
  * - `run.ts` — shared shell-execution helper
12
12
  */
13
- export { collectAll, collectPower } from './collectors.js';
14
- export type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerData, DiskData, NetworkData, ProcessData, GpuData } from './types.js';
13
+ export { collectAll, collectPower, collectPackets, collectTasks } from './collectors.js';
14
+ export type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerData, DiskData, NetworkData, ProcessData, GpuData, PacketData, NetworkProcess, TaskData } from './types.js';
@@ -19,7 +19,9 @@ export interface StatsData {
19
19
  processes: ProcessData[];
20
20
  power: PowerData | null;
21
21
  timestamp: string;
22
- }
22
+ packets: PacketData | null;
23
+ tasks: TaskData[];
24
+ }
23
25
 
24
26
  export interface GpuData {
25
27
  model: string;
@@ -35,6 +37,29 @@ export interface GpuData {
35
37
  combinedWatts?: number;
36
38
  }
37
39
 
40
+ export interface PacketData {
41
+ totalPackets: number;
42
+ rxPackets: number;
43
+ txPackets: number;
44
+ rxRate: number;
45
+ txRate: number;
46
+ connections: number;
47
+ topProcesses: { pid: number; command: string; rxBytes: number; txBytes: number }[];
48
+ /** Every process with an open network connection (unlike topProcesses, not truncated). */
49
+ allProcesses: { pid: number; command: string; rxBytes: number; txBytes: number; rxPackets?: number; txPackets?: number }[];
50
+ interfaces: { iface: string; rxPackets: number; txPackets: number; rxBytes: number; txBytes: number }[];
51
+ }
52
+
53
+ export interface TaskData {
54
+ pid: number;
55
+ user: string;
56
+ cpu: number;
57
+ mem: number;
58
+ command: string;
59
+ state: string;
60
+ runtime: number;
61
+ }
62
+
38
63
  export interface CpuData {
39
64
  brand: string;
40
65
  cores: number;
@@ -74,6 +99,9 @@ export interface BatteryData {
74
99
  cycles?: number;
75
100
  condition?: string;
76
101
  maxCapacityPercent?: number;
102
+ estimatedTimeToEmpty?: string;
103
+ dischargeRatePerHour?: number;
104
+ powerWatts?: number;
77
105
  }
78
106
 
79
107
  export interface DiskData {
@@ -92,6 +120,19 @@ export interface NetworkData {
92
120
  txBytes: number;
93
121
  rxPackets: number;
94
122
  txPackets: number;
123
+ rxPacketsPerSec?: number;
124
+ txPacketsPerSec?: number;
125
+ connections?: number;
126
+ topProcesses?: NetworkProcess[];
127
+ }
128
+
129
+ export interface NetworkProcess {
130
+ pid: number;
131
+ command: string;
132
+ rxBytes: number;
133
+ txBytes: number;
134
+ rxPackets: number;
135
+ txPackets: number;
95
136
  }
96
137
 
97
138
  export interface ProcessData {
@@ -0,0 +1,197 @@
1
+ import net from 'node:net';
2
+ import chalk from 'chalk';
3
+ import { encodeAuth, encodePing, encodePong, encodeDisconnect } from './protocol.js';
4
+ import type { P2PClientOptions } from './types.js';
5
+ import type { StatsData } from '../monitors/types.js';
6
+
7
+ interface ReadState {
8
+ buffer: Buffer;
9
+ expectingHeader: boolean;
10
+ messageLength: number;
11
+ }
12
+
13
+ export class P2PClient {
14
+ private socket: net.Socket | null = null;
15
+ private options: P2PClientOptions;
16
+ private running = false;
17
+ private readState: ReadState = { buffer: Buffer.alloc(0), expectingHeader: true, messageLength: 0 };
18
+ private onDataCallback: ((data: StatsData) => void) | null = null;
19
+ private onStatusCallback: ((msg: string) => void) | null = null;
20
+ private reconnectTimer: NodeJS.Timeout | null = null;
21
+ private pingHandle: NodeJS.Timeout | null = null;
22
+
23
+ constructor(options: P2PClientOptions) {
24
+ this.options = options;
25
+ }
26
+
27
+ setOnData(cb: (data: StatsData) => void): void {
28
+ this.onDataCallback = cb;
29
+ }
30
+
31
+ setOnStatus(cb: (msg: string) => void): void {
32
+ this.onStatusCallback = cb;
33
+ }
34
+
35
+ async connect(): Promise<void> {
36
+ return new Promise((resolve, reject) => {
37
+ this.socket = new net.Socket();
38
+
39
+ this.socket.on('data', (data) => this.onData(data));
40
+ this.socket.on('close', () => this.onClose());
41
+ this.socket.on('error', (err) => this.onError(err));
42
+ this.socket.on('timeout', () => this.onTimeout());
43
+
44
+ this.socket.setTimeout(10000);
45
+
46
+ this.socket.connect(this.options.port, this.options.host, () => {
47
+ this.running = true;
48
+ this.socket?.setTimeout(0);
49
+ this.socket?.write(encodeAuth(this.options.password));
50
+ this.status('Connecting...');
51
+ resolve();
52
+ });
53
+
54
+ this.socket.once('error', (err) => {
55
+ reject(err);
56
+ });
57
+ });
58
+ }
59
+
60
+ disconnect(): void {
61
+ this.running = false;
62
+ if (this.reconnectTimer) {
63
+ clearTimeout(this.reconnectTimer);
64
+ this.reconnectTimer = null;
65
+ }
66
+ if (this.pingHandle) {
67
+ clearInterval(this.pingHandle);
68
+ this.pingHandle = null;
69
+ }
70
+ if (this.socket) {
71
+ try {
72
+ this.socket.write(encodeDisconnect('Client closing'));
73
+ } catch {
74
+ // ignore
75
+ }
76
+ this.socket.destroy();
77
+ this.socket = null;
78
+ }
79
+ this.status('Disconnected');
80
+ }
81
+
82
+ private onData(data: Buffer): void {
83
+ this.readState.buffer = Buffer.concat([this.readState.buffer, data]);
84
+
85
+ while (this.readState.buffer.length > 0) {
86
+ if (this.readState.expectingHeader) {
87
+ if (this.readState.buffer.length >= 4) {
88
+ this.readState.messageLength = this.readState.buffer.readUInt32BE(0);
89
+ this.readState.buffer = this.readState.buffer.subarray(4);
90
+ this.readState.expectingHeader = false;
91
+ } else {
92
+ break;
93
+ }
94
+ }
95
+
96
+ if (!this.readState.expectingHeader) {
97
+ if (this.readState.buffer.length >= this.readState.messageLength) {
98
+ const body = this.readState.buffer.subarray(0, this.readState.messageLength);
99
+ this.readState.buffer = this.readState.buffer.subarray(this.readState.messageLength);
100
+ this.readState.expectingHeader = true;
101
+ this.handleMessage(body);
102
+ } else {
103
+ break;
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ private handleMessage(body: Buffer): void {
110
+ try {
111
+ const msg = JSON.parse(body.toString('utf-8'));
112
+
113
+ switch (msg.kind) {
114
+ case 'auth-ok':
115
+ this.status('Authenticated — receiving live data');
116
+ this.startPing();
117
+ break;
118
+
119
+ case 'auth-fail':
120
+ this.status(`Authentication failed: ${(msg.payload as { reason: string }).reason}`);
121
+ this.disconnect();
122
+ break;
123
+
124
+ case 'data':
125
+ if (this.onDataCallback && msg.payload?.stats) {
126
+ this.onDataCallback(msg.payload.stats as StatsData);
127
+ }
128
+ break;
129
+
130
+ case 'ping':
131
+ if (this.socket && !this.socket.destroyed) {
132
+ const ts = (msg.payload as { ts: number }).ts;
133
+ this.socket.write(encodePong(ts));
134
+ }
135
+ break;
136
+
137
+ case 'pong':
138
+ break;
139
+
140
+ case 'disconnect':
141
+ this.status(`Server disconnected: ${(msg.payload as { reason: string }).reason}`);
142
+ this.disconnect();
143
+ break;
144
+
145
+ default:
146
+ break;
147
+ }
148
+ } catch {
149
+ // ignore malformed messages
150
+ }
151
+ }
152
+
153
+ private startPing(): void {
154
+ this.pingHandle = setInterval(() => {
155
+ if (this.socket && !this.socket.destroyed) {
156
+ this.socket.write(encodePing());
157
+ }
158
+ }, 15000);
159
+ }
160
+
161
+ private onClose(): void {
162
+ if (this.running) {
163
+ this.status('Connection closed — reconnecting in 3s');
164
+ this.reconnectTimer = setTimeout(() => {
165
+ if (this.running) this.connect().catch(() => {});
166
+ }, 3000);
167
+ }
168
+ }
169
+
170
+ private onError(err: Error): void {
171
+ const code = (err as any).code;
172
+ if (code === 'ECONNREFUSED') {
173
+ this.status('Connection refused — is the server running?');
174
+ } else if (code === 'ETIMEDOUT') {
175
+ this.status('Connection timed out');
176
+ } else {
177
+ this.status(`Connection error: ${err.message}`);
178
+ }
179
+ }
180
+
181
+ private onTimeout(): void {
182
+ this.status('Connection timed out');
183
+ this.disconnect();
184
+ }
185
+
186
+ private status(msg: string): void {
187
+ if (this.onStatusCallback) {
188
+ this.onStatusCallback(msg);
189
+ }
190
+ }
191
+ }
192
+
193
+ export async function connectP2PClient(options: P2PClientOptions): Promise<P2PClient> {
194
+ const client = new P2PClient(options);
195
+ await client.connect();
196
+ return client;
197
+ }
@@ -0,0 +1,3 @@
1
+ export { P2PServer, startP2PServer } from './server.js';
2
+ export { P2PClient, connectP2PClient } from './client.js';
3
+ export type { P2PServerOptions, P2PClientOptions, P2PConnectionInfo } from './types.js';