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/README.md +78 -10
- package/dist/index.js +1041 -31
- package/package.json +2 -2
- package/src/formatters/index.ts +1 -1
- package/src/formatters/output.ts +47 -19
- package/src/formatters/render.ts +207 -16
- package/src/formatters/types.ts +4 -0
- package/src/history.ts +85 -47
- package/src/index.ts +140 -20
- package/src/live/export.ts +5 -2
- package/src/live/input.ts +296 -164
- package/src/live/render.ts +32 -28
- package/src/live/state.ts +41 -21
- package/src/monitors/collectors.ts +220 -17
- package/src/monitors/index.ts +2 -2
- package/src/monitors/types.ts +42 -1
- package/src/p2p/client.ts +197 -0
- package/src/p2p/index.ts +3 -0
- package/src/p2p/protocol.ts +71 -0
- package/src/p2p/server.ts +219 -0
- package/src/p2p/types.ts +71 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { P2PMessage, P2PMessageKind, P2PPayload } from './types.js';
|
|
2
|
+
import type { StatsData } from '../monitors/types.js';
|
|
3
|
+
|
|
4
|
+
const HEADER_SIZE = 4;
|
|
5
|
+
const MAX_MESSAGE_SIZE = 16 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
function buildMessage(kind: P2PMessageKind, payload: P2PPayload): Buffer {
|
|
8
|
+
const envelope: P2PMessage = { kind, payload };
|
|
9
|
+
const body = Buffer.from(JSON.stringify(envelope), 'utf-8');
|
|
10
|
+
const header = Buffer.alloc(HEADER_SIZE);
|
|
11
|
+
header.writeUInt32BE(body.length, 0);
|
|
12
|
+
return Buffer.concat([header, body]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function encodeAuth(password: string): Buffer {
|
|
16
|
+
return buildMessage('auth', { password });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function encodeAuthOk(): Buffer {
|
|
20
|
+
return buildMessage('auth-ok', { ok: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function encodeAuthFail(reason: string): Buffer {
|
|
24
|
+
return buildMessage('auth-fail', { ok: false, reason });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function encodeData(stats: StatsData): Buffer {
|
|
28
|
+
return buildMessage('data', { stats });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function encodePing(): Buffer {
|
|
32
|
+
return buildMessage('ping', { ts: Date.now() });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function encodePong(ts: number): Buffer {
|
|
36
|
+
return buildMessage('pong', { ts });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function encodeDisconnect(reason: string): Buffer {
|
|
40
|
+
return buildMessage('disconnect', { reason });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function decodeMessage(buf: Buffer): P2PMessage {
|
|
44
|
+
const str = buf.toString('utf-8');
|
|
45
|
+
const msg: P2PMessage = JSON.parse(str);
|
|
46
|
+
return msg;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readMessage(stream: { read(n: number): Buffer | null }): Buffer | null {
|
|
50
|
+
const header = stream.read(HEADER_SIZE);
|
|
51
|
+
if (!header || header.length < HEADER_SIZE) return null;
|
|
52
|
+
const length = header.readUInt32BE(0);
|
|
53
|
+
if (length > MAX_MESSAGE_SIZE) {
|
|
54
|
+
throw new Error(`Message too large: ${length} bytes`);
|
|
55
|
+
}
|
|
56
|
+
const body = stream.read(length);
|
|
57
|
+
if (!body || body.length < length) return null;
|
|
58
|
+
return body;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function readAll(stream: { read(n: number): Buffer | null }, total: number): Buffer {
|
|
62
|
+
const chunks: Buffer[] = [];
|
|
63
|
+
let remaining = total;
|
|
64
|
+
while (remaining > 0) {
|
|
65
|
+
const chunk = stream.read(remaining);
|
|
66
|
+
if (!chunk) break;
|
|
67
|
+
chunks.push(chunk);
|
|
68
|
+
remaining -= chunk.length;
|
|
69
|
+
}
|
|
70
|
+
return Buffer.concat(chunks);
|
|
71
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { collectAll } from '../monitors/index.js';
|
|
4
|
+
import { encodeAuthOk, encodeAuthFail, encodeData, encodePing, encodePong, encodeDisconnect } from './protocol.js';
|
|
5
|
+
import type { P2PServerOptions, P2PConnectionInfo } from './types.js';
|
|
6
|
+
|
|
7
|
+
interface Peer {
|
|
8
|
+
socket: net.Socket;
|
|
9
|
+
authenticated: boolean;
|
|
10
|
+
intervalHandle: NodeJS.Timeout | null;
|
|
11
|
+
pingHandle: NodeJS.Timeout | null;
|
|
12
|
+
readBuffer: Buffer;
|
|
13
|
+
readExpectingHeader: boolean;
|
|
14
|
+
readMessageLength: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const HEADER_SIZE = 4;
|
|
18
|
+
|
|
19
|
+
function readMessageFromPeer(peer: Peer): Buffer | null {
|
|
20
|
+
while (peer.readBuffer.length > 0) {
|
|
21
|
+
if (peer.readExpectingHeader) {
|
|
22
|
+
if (peer.readBuffer.length >= HEADER_SIZE) {
|
|
23
|
+
peer.readMessageLength = peer.readBuffer.readUInt32BE(0);
|
|
24
|
+
peer.readBuffer = peer.readBuffer.subarray(HEADER_SIZE);
|
|
25
|
+
peer.readExpectingHeader = false;
|
|
26
|
+
} else {
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!peer.readExpectingHeader) {
|
|
32
|
+
if (peer.readBuffer.length >= peer.readMessageLength) {
|
|
33
|
+
const body = peer.readBuffer.subarray(0, peer.readMessageLength);
|
|
34
|
+
peer.readBuffer = peer.readBuffer.subarray(peer.readMessageLength);
|
|
35
|
+
peer.readExpectingHeader = true;
|
|
36
|
+
return body;
|
|
37
|
+
} else {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class P2PServer {
|
|
46
|
+
private server: net.Server | null = null;
|
|
47
|
+
private peers: Map<net.Socket, Peer> = new Map();
|
|
48
|
+
private options: P2PServerOptions;
|
|
49
|
+
private running = false;
|
|
50
|
+
|
|
51
|
+
constructor(options: P2PServerOptions) {
|
|
52
|
+
this.options = options;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get info(): P2PConnectionInfo {
|
|
56
|
+
return {
|
|
57
|
+
host: this.options.host,
|
|
58
|
+
port: this.options.port,
|
|
59
|
+
peersConnected: this.peers.size,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async start(): Promise<void> {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
this.server = net.createServer((socket) => this.onConnection(socket));
|
|
66
|
+
this.server.on('error', (err) => reject(err));
|
|
67
|
+
this.server.listen(this.options.port, this.options.host, () => {
|
|
68
|
+
this.running = true;
|
|
69
|
+
const addr = this.server?.address();
|
|
70
|
+
const bound = addr ? (typeof addr === 'object' ? `${addr.address}:${addr.port}` : String(addr)) : 'unknown';
|
|
71
|
+
console.log(chalk.green(`P2P server listening on ${bound}`));
|
|
72
|
+
console.log(chalk.dim(` Password: ${this.options.password}`));
|
|
73
|
+
console.log(chalk.dim(` Interval: ${this.options.intervalMs}ms`));
|
|
74
|
+
resolve();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
stop(): void {
|
|
80
|
+
this.running = false;
|
|
81
|
+
for (const peer of this.peers.values()) {
|
|
82
|
+
this.disconnectPeer(peer, 'Server shutting down');
|
|
83
|
+
}
|
|
84
|
+
this.peers.clear();
|
|
85
|
+
if (this.server) {
|
|
86
|
+
this.server.close();
|
|
87
|
+
this.server = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private onConnection(socket: net.Socket): void {
|
|
92
|
+
const peer: Peer = {
|
|
93
|
+
socket,
|
|
94
|
+
authenticated: false,
|
|
95
|
+
intervalHandle: null,
|
|
96
|
+
pingHandle: null,
|
|
97
|
+
readBuffer: Buffer.alloc(0),
|
|
98
|
+
readExpectingHeader: true,
|
|
99
|
+
readMessageLength: 0,
|
|
100
|
+
};
|
|
101
|
+
this.peers.set(socket, peer);
|
|
102
|
+
|
|
103
|
+
const remoteAddr = socket.remoteAddress
|
|
104
|
+
? `${socket.remoteAddress}:${socket.remotePort}`
|
|
105
|
+
: 'unknown';
|
|
106
|
+
console.log(chalk.cyan(`Peer connected: ${remoteAddr}`));
|
|
107
|
+
|
|
108
|
+
socket.on('data', (data) => this.onData(peer, data));
|
|
109
|
+
socket.on('close', () => this.onPeerClose(peer));
|
|
110
|
+
socket.on('error', (err) => this.onPeerError(peer, err));
|
|
111
|
+
|
|
112
|
+
socket.setTimeout(30000);
|
|
113
|
+
socket.on('timeout', () => {
|
|
114
|
+
if (!peer.authenticated) {
|
|
115
|
+
this.disconnectPeer(peer, 'Authentication timeout');
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private onData(peer: Peer, data: Buffer): void {
|
|
121
|
+
peer.readBuffer = Buffer.concat([peer.readBuffer, data]);
|
|
122
|
+
|
|
123
|
+
if (!peer.authenticated) {
|
|
124
|
+
const body = readMessageFromPeer(peer);
|
|
125
|
+
if (body) {
|
|
126
|
+
this.handleAuthMessage(peer, body);
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private handleAuthMessage(peer: Peer, body: Buffer): void {
|
|
133
|
+
try {
|
|
134
|
+
const msg = JSON.parse(body.toString('utf-8'));
|
|
135
|
+
if (msg.kind === 'auth' && typeof msg.payload?.password === 'string') {
|
|
136
|
+
if (msg.payload.password === this.options.password) {
|
|
137
|
+
peer.authenticated = true;
|
|
138
|
+
peer.socket.write(encodeAuthOk());
|
|
139
|
+
console.log(chalk.green(`Peer authenticated: ${peer.socket.remoteAddress}`));
|
|
140
|
+
this.startDataStream(peer);
|
|
141
|
+
this.startPing(peer);
|
|
142
|
+
} else {
|
|
143
|
+
peer.socket.write(encodeAuthFail('Invalid password'));
|
|
144
|
+
this.disconnectPeer(peer, 'Authentication failed');
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
peer.socket.write(encodeAuthFail('Expected auth message first'));
|
|
148
|
+
this.disconnectPeer(peer, 'Expected auth message');
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
peer.socket.write(encodeAuthFail('Invalid message format'));
|
|
152
|
+
this.disconnectPeer(peer, 'Invalid message format');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private startDataStream(peer: Peer): void {
|
|
157
|
+
const send = async () => {
|
|
158
|
+
if (!peer.authenticated || peer.socket.destroyed) return;
|
|
159
|
+
try {
|
|
160
|
+
const data = await collectAll({ detailed: this.options.detailed });
|
|
161
|
+
peer.socket.write(encodeData(data));
|
|
162
|
+
} catch {
|
|
163
|
+
// skip failed tick
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
send();
|
|
168
|
+
peer.intervalHandle = setInterval(send, this.options.intervalMs);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private startPing(peer: Peer): void {
|
|
172
|
+
peer.pingHandle = setInterval(() => {
|
|
173
|
+
if (peer.socket.destroyed) return;
|
|
174
|
+
peer.socket.write(encodePing());
|
|
175
|
+
}, 5000);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private onPeerClose(peer: Peer): void {
|
|
179
|
+
this.cleanupPeer(peer);
|
|
180
|
+
console.log(chalk.yellow(`Peer disconnected: ${peer.socket.remoteAddress}`));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private onPeerError(peer: Peer, err: Error): void {
|
|
184
|
+
const code = (err as any).code;
|
|
185
|
+
if (code === 'ECONNRESET') {
|
|
186
|
+
console.log(chalk.yellow(`Peer connection reset: ${peer.socket.remoteAddress}`));
|
|
187
|
+
} else {
|
|
188
|
+
console.log(chalk.red(`Peer error: ${err.message}`));
|
|
189
|
+
}
|
|
190
|
+
this.cleanupPeer(peer);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private disconnectPeer(peer: Peer, reason: string): void {
|
|
194
|
+
try {
|
|
195
|
+
peer.socket.write(encodeDisconnect(reason));
|
|
196
|
+
} catch {
|
|
197
|
+
// socket may already be closed
|
|
198
|
+
}
|
|
199
|
+
peer.socket.destroy();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private cleanupPeer(peer: Peer): void {
|
|
203
|
+
if (peer.intervalHandle) {
|
|
204
|
+
clearInterval(peer.intervalHandle);
|
|
205
|
+
peer.intervalHandle = null;
|
|
206
|
+
}
|
|
207
|
+
if (peer.pingHandle) {
|
|
208
|
+
clearInterval(peer.pingHandle);
|
|
209
|
+
peer.pingHandle = null;
|
|
210
|
+
}
|
|
211
|
+
this.peers.delete(peer.socket);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function startP2PServer(options: P2PServerOptions): Promise<P2PServer> {
|
|
216
|
+
const server = new P2PServer(options);
|
|
217
|
+
await server.start();
|
|
218
|
+
return server;
|
|
219
|
+
}
|
package/src/p2p/types.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P2P live data transfer protocol types.
|
|
3
|
+
*
|
|
4
|
+
* Defines the message shapes exchanged between a pyre server
|
|
5
|
+
* (host) and a pyre client (peer) over a TCP connection.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type P2PMessageKind = 'auth' | 'auth-ok' | 'auth-fail' | 'data' | 'ping' | 'pong' | 'disconnect';
|
|
9
|
+
|
|
10
|
+
export interface P2PMessage {
|
|
11
|
+
kind: P2PMessageKind;
|
|
12
|
+
payload: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AuthMessage {
|
|
16
|
+
password: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AuthOkMessage {
|
|
20
|
+
ok: true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AuthFailMessage {
|
|
24
|
+
ok: false;
|
|
25
|
+
reason: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DataMessage {
|
|
29
|
+
stats: import('../monitors/types.js').StatsData;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PingMessage {
|
|
33
|
+
ts: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PongMessage {
|
|
37
|
+
ts: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DisconnectMessage {
|
|
41
|
+
reason: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type P2PPayload =
|
|
45
|
+
| AuthMessage
|
|
46
|
+
| AuthOkMessage
|
|
47
|
+
| AuthFailMessage
|
|
48
|
+
| DataMessage
|
|
49
|
+
| PingMessage
|
|
50
|
+
| PongMessage
|
|
51
|
+
| DisconnectMessage;
|
|
52
|
+
|
|
53
|
+
export interface P2PServerOptions {
|
|
54
|
+
host: string;
|
|
55
|
+
port: number;
|
|
56
|
+
password: string;
|
|
57
|
+
intervalMs: number;
|
|
58
|
+
detailed: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface P2PClientOptions {
|
|
62
|
+
host: string;
|
|
63
|
+
port: number;
|
|
64
|
+
password: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface P2PConnectionInfo {
|
|
68
|
+
host: string;
|
|
69
|
+
port: number;
|
|
70
|
+
peersConnected: number;
|
|
71
|
+
}
|