gnutella 1.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.
Files changed (45) hide show
  1. package/CLI.md +189 -0
  2. package/DEVELOPER.md +193 -0
  3. package/LICENSE +674 -0
  4. package/QUICKSTART.md +133 -0
  5. package/README.md +74 -0
  6. package/bin/gnutella.ts +15 -0
  7. package/gnutella.json.example +18 -0
  8. package/package.json +72 -0
  9. package/src/cli.ts +692 -0
  10. package/src/cli_shared.ts +359 -0
  11. package/src/const.ts +138 -0
  12. package/src/gwebcache/bootstrap.ts +491 -0
  13. package/src/gwebcache/response.ts +391 -0
  14. package/src/gwebcache/shared.ts +116 -0
  15. package/src/gwebcache/types.ts +187 -0
  16. package/src/gwebcache_client.ts +13 -0
  17. package/src/protocol/browse_host.ts +552 -0
  18. package/src/protocol/client_blocking.ts +29 -0
  19. package/src/protocol/codec.ts +715 -0
  20. package/src/protocol/content_urn.ts +170 -0
  21. package/src/protocol/core_utils.ts +43 -0
  22. package/src/protocol/file_server.ts +245 -0
  23. package/src/protocol/ggep.ts +168 -0
  24. package/src/protocol/handshake.ts +199 -0
  25. package/src/protocol/http_download_reader.ts +112 -0
  26. package/src/protocol/magnet.ts +176 -0
  27. package/src/protocol/node.ts +416 -0
  28. package/src/protocol/node_handshake.ts +992 -0
  29. package/src/protocol/node_lifecycle.ts +210 -0
  30. package/src/protocol/node_protocol_runtime.ts +949 -0
  31. package/src/protocol/node_qrp_runtime.ts +97 -0
  32. package/src/protocol/node_query_routing.ts +208 -0
  33. package/src/protocol/node_state.ts +745 -0
  34. package/src/protocol/node_tls.ts +257 -0
  35. package/src/protocol/node_topology.ts +141 -0
  36. package/src/protocol/node_transfer.ts +455 -0
  37. package/src/protocol/node_types.ts +106 -0
  38. package/src/protocol/peer_state.ts +675 -0
  39. package/src/protocol/qrp.ts +549 -0
  40. package/src/protocol/query_search.ts +29 -0
  41. package/src/protocol/share_index.ts +131 -0
  42. package/src/protocol/share_library.ts +246 -0
  43. package/src/protocol.ts +36 -0
  44. package/src/shared.ts +236 -0
  45. package/src/types.ts +452 -0
package/src/cli.ts ADDED
@@ -0,0 +1,692 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import readline from "node:readline";
4
+
5
+ import {
6
+ CLI_SHUTDOWN_TIMEOUT_MS,
7
+ CLI_HELP_LINES,
8
+ PROMPT_THROBBER_FRAMES,
9
+ PROMPT_THROBBER_INTERVAL_MS,
10
+ } from "./const";
11
+ import {
12
+ displayResultCount,
13
+ errMsg,
14
+ parseCli,
15
+ printPeers,
16
+ printResultInfo,
17
+ printResultMagnet,
18
+ printResults,
19
+ printShares,
20
+ printStatus,
21
+ runExecCommands,
22
+ } from "./cli_shared";
23
+ import { GnutellaServent, loadDoc, writeDoc } from "./protocol";
24
+ import { sleep, splitArgs } from "./shared";
25
+ import type { ConnectPeerResult, GnutellaEvent } from "./types";
26
+
27
+ type MonitorLogEntry = {
28
+ line: string;
29
+ tags: string[];
30
+ };
31
+
32
+ type CliSession = {
33
+ rl: readline.Interface | null;
34
+ node: GnutellaServent;
35
+ monitorEnabled: boolean;
36
+ monitorIgnoreTokens: Set<string>;
37
+ promptFrame: number;
38
+ promptTimer: ReturnType<typeof setTimeout> | null;
39
+ promptInitial: boolean;
40
+ shutdown: (() => Promise<void>) | null;
41
+ };
42
+
43
+ function createCliSession(node: GnutellaServent): CliSession {
44
+ return {
45
+ rl: null,
46
+ node,
47
+ monitorEnabled: false,
48
+ monitorIgnoreTokens: new Set<string>(),
49
+ promptFrame: PROMPT_THROBBER_FRAMES.length - 1,
50
+ promptTimer: null,
51
+ promptInitial: true,
52
+ shutdown: null,
53
+ };
54
+ }
55
+
56
+ function padNum(value: number, width: number): string {
57
+ return String(value).padStart(width, "0");
58
+ }
59
+
60
+ function promptThrobber(session: CliSession): string {
61
+ if (session.promptInitial) return " ";
62
+ return PROMPT_THROBBER_FRAMES[session.promptFrame] || " ";
63
+ }
64
+
65
+ function peerLimitDisplay(node: GnutellaServent): number {
66
+ const c = node.config();
67
+ if (c.nodeMode === "ultrapeer") return c.maxConnections;
68
+ if (c.nodeMode === "leaf") return c.maxUltrapeerConnections;
69
+ return c.maxConnections;
70
+ }
71
+
72
+ function promptText(session: CliSession): string {
73
+ const status = session.node.getStatus();
74
+ const peerLimit = peerLimitDisplay(session.node);
75
+ const peerWidth = Math.max(
76
+ 2,
77
+ String(status.peers).length,
78
+ String(peerLimit).length,
79
+ );
80
+ return `[${padNum(status.peers, peerWidth)}/${padNum(peerLimit, peerWidth)}${promptThrobber(session)}${padNum(displayResultCount(status.results), 3)}] `;
81
+ }
82
+
83
+ function stopPromptThrobber(session: CliSession): void {
84
+ if (session.promptTimer) clearTimeout(session.promptTimer);
85
+ session.promptTimer = null;
86
+ session.promptFrame = PROMPT_THROBBER_FRAMES.length - 1;
87
+ session.promptInitial = true;
88
+ }
89
+
90
+ function stepPromptThrobber(session: CliSession): void {
91
+ if (session.promptFrame >= PROMPT_THROBBER_FRAMES.length - 1) {
92
+ session.promptTimer = null;
93
+ redrawPrompt(session);
94
+ return;
95
+ }
96
+ session.promptTimer = setTimeout(() => {
97
+ session.promptFrame++;
98
+ redrawPrompt(session);
99
+ stepPromptThrobber(session);
100
+ }, PROMPT_THROBBER_INTERVAL_MS);
101
+ }
102
+
103
+ function throbPrompt(session: CliSession): void {
104
+ session.promptInitial = false;
105
+ if (!process.stdin.isTTY) return;
106
+ if (session.promptTimer) clearTimeout(session.promptTimer);
107
+ session.promptFrame = 0;
108
+ redrawPrompt(session);
109
+ stepPromptThrobber(session);
110
+ }
111
+
112
+ function redrawPrompt(session: CliSession): void {
113
+ if (!session.rl || !process.stdin.isTTY) return;
114
+ session.rl.setPrompt(promptText(session));
115
+ session.rl.prompt(true);
116
+ }
117
+
118
+ function log(session: CliSession, msg: string): void {
119
+ process.stdout.write(`${msg}\n`);
120
+ redrawPrompt(session);
121
+ }
122
+
123
+ function shortDescriptorId(hex: string): string {
124
+ return hex.slice(0, 8);
125
+ }
126
+
127
+ function quoted(value: string): string {
128
+ return JSON.stringify(value);
129
+ }
130
+
131
+ function describePeer(
132
+ event: Extract<GnutellaEvent, { peer: { remoteLabel: string } }>,
133
+ ): string {
134
+ const parts = [
135
+ event.peer.remoteLabel,
136
+ `dir=${event.peer.outbound ? "out" : "in"}`,
137
+ `flags=${event.peer.compression ? "Z" : "-"}${event.peer.tls ? "L" : "-"}`,
138
+ ];
139
+ if (event.peer.userAgent)
140
+ parts.push(`agent=${quoted(event.peer.userAgent)}`);
141
+ return parts.join(" ");
142
+ }
143
+
144
+ function monitorEntry(line: string, ...tags: string[]): MonitorLogEntry {
145
+ return { line, tags };
146
+ }
147
+
148
+ function setMonitorIgnoreTokens(
149
+ session: CliSession,
150
+ tokens: string[],
151
+ ): void {
152
+ session.monitorIgnoreTokens = new Set(tokens);
153
+ }
154
+
155
+ function shouldIgnoreMonitorEntry(
156
+ session: CliSession,
157
+ entry: MonitorLogEntry,
158
+ ): boolean {
159
+ return entry.tags.some((tag) => session.monitorIgnoreTokens.has(tag));
160
+ }
161
+
162
+ function formatLifecycleMonitorEvent(
163
+ event: GnutellaEvent,
164
+ ): MonitorLogEntry | undefined {
165
+ switch (event.type) {
166
+ case "STARTED":
167
+ return monitorEntry(
168
+ `[started] listen=${event.listenHost}:${event.listenPort} advertised=${event.advertisedHost}:${event.advertisedPort}`,
169
+ "STARTED",
170
+ );
171
+ case "IDENTITY":
172
+ return monitorEntry(
173
+ `[identity] serventId=${event.serventIdHex}`,
174
+ "IDENTITY",
175
+ );
176
+ case "SHARES_REFRESHED":
177
+ return monitorEntry(
178
+ `[shares] count=${event.count} totalKiB=${event.totalKBytes}`,
179
+ "SHARES_REFRESHED",
180
+ );
181
+ case "MAINTENANCE_ERROR":
182
+ return monitorEntry(
183
+ `[maintenance] op=${event.operation} message=${quoted(event.message)}`,
184
+ "MAINTENANCE_ERROR",
185
+ );
186
+ case "PROBE_REJECTED":
187
+ return monitorEntry(
188
+ `[probe rejected] message=${quoted(event.message)}`,
189
+ "PROBE_REJECTED",
190
+ );
191
+ }
192
+ }
193
+
194
+ function formatHandshakeMonitorEvent(
195
+ event: GnutellaEvent,
196
+ ): MonitorLogEntry | undefined {
197
+ if (event.type !== "HANDSHAKE_DEBUG") return;
198
+ const prefix = event.phase.includes("failed") ? "[warning] " : "";
199
+ return monitorEntry(
200
+ `${prefix}[hs ${event.direction} ${event.phase}] peer=${event.peer} ${event.message}`,
201
+ "HANDSHAKE",
202
+ `HANDSHAKE:${event.direction.toUpperCase()}`,
203
+ `HANDSHAKE:${event.phase.toUpperCase()}`,
204
+ );
205
+ }
206
+
207
+ function formatPeerMonitorEvent(
208
+ event: GnutellaEvent,
209
+ ): MonitorLogEntry | undefined {
210
+ switch (event.type) {
211
+ case "PEER_CONNECTED":
212
+ return monitorEntry(
213
+ `[peer up] ${describePeer(event)}`,
214
+ "PEER_CONNECTED",
215
+ );
216
+ case "PEER_DROPPED":
217
+ return monitorEntry(
218
+ `[peer down] ${describePeer(event)} message=${quoted(event.message)}`,
219
+ "PEER_DROPPED",
220
+ );
221
+ case "PEER_MESSAGE_RECEIVED":
222
+ return monitorEntry(
223
+ `[rx] ${event.payloadTypeName} id=${shortDescriptorId(event.descriptorIdHex)} ttl=${event.ttl} hops=${event.hops} len=${event.payloadLength} from=${event.peer.remoteLabel}`,
224
+ "PEER_MESSAGE_RECEIVED",
225
+ event.payloadTypeName,
226
+ `RX:${event.payloadTypeName}`,
227
+ );
228
+ case "PEER_MESSAGE_SENT":
229
+ return monitorEntry(
230
+ `[tx] ${event.payloadTypeName} id=${shortDescriptorId(event.descriptorIdHex)} ttl=${event.ttl} hops=${event.hops} len=${event.payloadLength} to=${event.peer.remoteLabel}`,
231
+ "PEER_MESSAGE_SENT",
232
+ event.payloadTypeName,
233
+ `TX:${event.payloadTypeName}`,
234
+ );
235
+ case "PONG":
236
+ return monitorEntry(
237
+ `[pong] ${event.ip}:${event.port} files=${event.files} kbytes=${event.kbytes}`,
238
+ "PONG",
239
+ "EVENT:PONG",
240
+ );
241
+ }
242
+ }
243
+
244
+ function formatQueryMonitorEvent(
245
+ event: GnutellaEvent,
246
+ ): MonitorLogEntry | undefined {
247
+ switch (event.type) {
248
+ case "QUERY_RECEIVED":
249
+ return monitorEntry(
250
+ `[query rx] id=${shortDescriptorId(event.descriptorIdHex)} ttl=${event.ttl} hops=${event.hops} from=${event.peer.remoteLabel} urns=${event.urns.length} search=${quoted(event.search)}`,
251
+ "QUERY_RECEIVED",
252
+ "EVENT:QUERY_RECEIVED",
253
+ "QUERY",
254
+ "RX:QUERY",
255
+ );
256
+ case "QUERY_RESULT":
257
+ return monitorEntry(
258
+ `[query hit] #${event.hit.resultNo} via=${event.hit.viaPeerKey} remote=${event.hit.remoteHost}:${event.hit.remotePort} size=${event.hit.fileSize} name=${quoted(event.hit.fileName)}`,
259
+ "QUERY_RESULT",
260
+ "EVENT:QUERY_RESULT",
261
+ "QUERY_HIT",
262
+ );
263
+ case "PING_SENT":
264
+ return monitorEntry(
265
+ `[ping tx] id=${shortDescriptorId(event.descriptorIdHex)} ttl=${event.ttl}`,
266
+ "PING_SENT",
267
+ "EVENT:PING_SENT",
268
+ "PING",
269
+ "TX:PING",
270
+ );
271
+ case "QUERY_SENT":
272
+ return monitorEntry(
273
+ `[query tx] id=${shortDescriptorId(event.descriptorIdHex)} ttl=${event.ttl} search=${quoted(event.search)}`,
274
+ "QUERY_SENT",
275
+ "EVENT:QUERY_SENT",
276
+ "QUERY",
277
+ "TX:QUERY",
278
+ );
279
+ case "QUERY_SKIPPED":
280
+ return monitorEntry(
281
+ `[query skip] reason=${event.reason}`,
282
+ "QUERY_SKIPPED",
283
+ );
284
+ }
285
+ }
286
+
287
+ function formatTransferMonitorEvent(
288
+ event: GnutellaEvent,
289
+ ): MonitorLogEntry | undefined {
290
+ switch (event.type) {
291
+ case "PUSH_REQUESTED":
292
+ return monitorEntry(
293
+ `[push requested] fileIndex=${event.fileIndex} ip=${event.ip}:${event.port} name=${quoted(event.fileName)}`,
294
+ "PUSH_REQUESTED",
295
+ "PUSH",
296
+ );
297
+ case "PUSH_CALLBACK_FAILED":
298
+ return monitorEntry(
299
+ `[push callback failed] message=${quoted(event.message)}`,
300
+ "PUSH_CALLBACK_FAILED",
301
+ );
302
+ case "PUSH_UPLOAD_FAILED":
303
+ return monitorEntry(
304
+ `[push upload failed] message=${quoted(event.message)}`,
305
+ "PUSH_UPLOAD_FAILED",
306
+ );
307
+ case "DOWNLOAD_SUCCEEDED":
308
+ return monitorEntry(
309
+ `[download ok] mode=${event.mode} result=${event.resultNo} remote=${event.remoteHost}:${event.remotePort} path=${quoted(event.destPath)}`,
310
+ "DOWNLOAD_SUCCEEDED",
311
+ );
312
+ case "DOWNLOAD_DIRECT_FAILED":
313
+ return monitorEntry(
314
+ `[download failed] result=${event.resultNo} remote=${event.remoteHost}:${event.remotePort} path=${quoted(event.destPath)} message=${quoted(event.message)}`,
315
+ "DOWNLOAD_DIRECT_FAILED",
316
+ );
317
+ }
318
+ }
319
+
320
+ function formatMonitorEvent(
321
+ event: GnutellaEvent,
322
+ ): MonitorLogEntry | undefined {
323
+ return (
324
+ formatLifecycleMonitorEvent(event) ||
325
+ formatHandshakeMonitorEvent(event) ||
326
+ formatPeerMonitorEvent(event) ||
327
+ formatQueryMonitorEvent(event) ||
328
+ formatTransferMonitorEvent(event)
329
+ );
330
+ }
331
+
332
+ function handleNodeEvent(session: CliSession, event: GnutellaEvent): void {
333
+ if (!session.monitorEnabled) {
334
+ if (event.type === "PEER_MESSAGE_RECEIVED") {
335
+ throbPrompt(session);
336
+ return;
337
+ }
338
+ redrawPrompt(session);
339
+ return;
340
+ }
341
+ const entry = formatMonitorEvent(event);
342
+ if (entry) {
343
+ if (shouldIgnoreMonitorEntry(session, entry)) {
344
+ if (event.type === "PEER_MESSAGE_RECEIVED") throbPrompt(session);
345
+ else redrawPrompt(session);
346
+ return;
347
+ }
348
+ log(session, entry.line);
349
+ return;
350
+ }
351
+ redrawPrompt(session);
352
+ }
353
+
354
+ function printHelp(session: CliSession): void {
355
+ for (const line of CLI_HELP_LINES) log(session, line);
356
+ }
357
+
358
+ function logConnectResult(
359
+ session: CliSession,
360
+ result: ConnectPeerResult,
361
+ ): void {
362
+ switch (result.status) {
363
+ case "connected":
364
+ log(session, `peer ${result.peer} connected`);
365
+ return;
366
+ case "already-connected":
367
+ log(session, `peer ${result.peer} already connected`);
368
+ return;
369
+ case "dialing":
370
+ log(session, `peer ${result.peer} already dialing`);
371
+ return;
372
+ case "saved":
373
+ log(
374
+ session,
375
+ `peer ${result.peer} saved for retry; connect failed: ${result.message}`,
376
+ );
377
+ return;
378
+ case "blocked":
379
+ log(session, `peer ${result.peer} is blocked`);
380
+ return;
381
+ }
382
+ }
383
+
384
+ function pluralize(value: number, noun: string): string {
385
+ return `${value} ${noun}${value === 1 ? "" : "s"}`;
386
+ }
387
+
388
+ function logBlockedIps(session: CliSession): void {
389
+ const blockedIps = session.node.getBlockedIps();
390
+ if (!blockedIps.length) {
391
+ log(session, "no blocked IPs");
392
+ return;
393
+ }
394
+ log(session, blockedIps.join("\n"));
395
+ }
396
+
397
+ function logBlockResult(
398
+ session: CliSession,
399
+ result: ReturnType<GnutellaServent["blockIp"]>,
400
+ ): void {
401
+ if (result.status === "already-blocked") {
402
+ log(session, `ip ${result.ip} already blocked`);
403
+ return;
404
+ }
405
+ const details: string[] = [];
406
+ if (result.droppedPeers > 0)
407
+ details.push(pluralize(result.droppedPeers, "peer"));
408
+ if (result.removedKnownPeers > 0)
409
+ details.push(pluralize(result.removedKnownPeers, "known peer"));
410
+ if (!details.length) {
411
+ log(session, `ip ${result.ip} blocked`);
412
+ return;
413
+ }
414
+ log(session, `ip ${result.ip} blocked; removed ${details.join(", ")}`);
415
+ }
416
+
417
+ function logUnblockResult(
418
+ session: CliSession,
419
+ result: ReturnType<GnutellaServent["unblockIp"]>,
420
+ ): void {
421
+ if (result.status === "not-blocked") {
422
+ log(session, `ip ${result.ip} is not blocked`);
423
+ return;
424
+ }
425
+ log(session, `ip ${result.ip} unblocked`);
426
+ }
427
+
428
+ async function handleConnectCommand(
429
+ session: CliSession,
430
+ args: string[],
431
+ ): Promise<boolean> {
432
+ if (args.length !== 2) throw new Error("usage: connect <ip:port>");
433
+ logConnectResult(session, await session.node.connectToPeer(args[1]));
434
+ return true;
435
+ }
436
+
437
+ async function handleDownloadCommand(
438
+ session: CliSession,
439
+ args: string[],
440
+ ): Promise<boolean> {
441
+ if (args.length < 2)
442
+ throw new Error("usage: download <resultNo> [destPath]");
443
+ await session.node.downloadResult(Number(args[1]), args[2]);
444
+ return true;
445
+ }
446
+
447
+ function pingTtlFor(node: GnutellaServent, args: string[]): number {
448
+ return args[1] ? Number(args[1]) : node.config().defaultPingTtl;
449
+ }
450
+
451
+ async function handleBrowseCommand(
452
+ session: CliSession,
453
+ args: string[],
454
+ ): Promise<boolean> {
455
+ if (args.length !== 2)
456
+ throw new Error("usage: browse <peerKey|ip:port>");
457
+ const added = await session.node.browsePeer(args[1]);
458
+ log(
459
+ session,
460
+ added > 0
461
+ ? `browse loaded ${added} result${added === 1 ? "" : "s"} from ${args[1]}`
462
+ : `browse returned no results from ${args[1]}`,
463
+ );
464
+ return true;
465
+ }
466
+
467
+ async function handleInfoCommand(
468
+ session: CliSession,
469
+ args: string[],
470
+ ): Promise<boolean> {
471
+ if (args.length !== 2) throw new Error("usage: info <resultNo>");
472
+ const resultNo = Number(args[1]);
473
+ if (!Number.isInteger(resultNo) || resultNo < 1)
474
+ throw new Error("usage: info <resultNo>");
475
+ printResultInfo(session.node, resultNo, (msg) => log(session, msg));
476
+ return true;
477
+ }
478
+
479
+ async function handleMagnetCommand(
480
+ session: CliSession,
481
+ args: string[],
482
+ ): Promise<boolean> {
483
+ if (args.length !== 2) throw new Error("usage: magnet <resultNo>");
484
+ const resultNo = Number(args[1]);
485
+ if (!Number.isInteger(resultNo) || resultNo < 1)
486
+ throw new Error("usage: magnet <resultNo>");
487
+ printResultMagnet(session.node, resultNo, (msg) => log(session, msg));
488
+ return true;
489
+ }
490
+
491
+ type CommandHandler = (
492
+ session: CliSession,
493
+ args: string[],
494
+ ) => Promise<boolean>;
495
+
496
+ const COMMAND_ALIASES: Record<string, string> = {
497
+ exit: "quit",
498
+ search: "query",
499
+ };
500
+
501
+ const COMMAND_HANDLERS: Record<string, CommandHandler> = {
502
+ help: async (session) => {
503
+ printHelp(session);
504
+ return true;
505
+ },
506
+ monitor: async (session, args) => {
507
+ if (args.length !== 1) throw new Error("usage: monitor");
508
+ session.monitorEnabled = !session.monitorEnabled;
509
+ log(session, `monitor ${session.monitorEnabled ? "on" : "off"}`);
510
+ return true;
511
+ },
512
+ status: async (session) => {
513
+ printStatus(session.node, (msg) => log(session, msg));
514
+ return true;
515
+ },
516
+ peers: async (session) => {
517
+ printPeers(session.node, (msg) => log(session, msg));
518
+ return true;
519
+ },
520
+ blocked: async (session, args) => {
521
+ if (args.length !== 1) throw new Error("usage: blocked");
522
+ logBlockedIps(session);
523
+ return true;
524
+ },
525
+ block: async (session, args) => {
526
+ if (args.length !== 2) throw new Error("usage: block <ipv4>");
527
+ logBlockResult(session, session.node.blockIp(args[1]));
528
+ return true;
529
+ },
530
+ unblock: async (session, args) => {
531
+ if (args.length !== 2) throw new Error("usage: unblock <ipv4>");
532
+ logUnblockResult(session, session.node.unblockIp(args[1]));
533
+ return true;
534
+ },
535
+ connect: handleConnectCommand,
536
+ shares: async (session) => {
537
+ printShares(session.node, (msg) => log(session, msg));
538
+ return true;
539
+ },
540
+ results: async (session) => {
541
+ printResults(session.node, (msg) => log(session, msg));
542
+ return true;
543
+ },
544
+ clear: async (session) => {
545
+ session.node.clearResults();
546
+ log(session, "results cleared");
547
+ return true;
548
+ },
549
+ ping: async (session, args) => {
550
+ session.node.sendPing(pingTtlFor(session.node, args));
551
+ return true;
552
+ },
553
+ query: async (session, args) => {
554
+ session.node.sendQuery(args.slice(1).join(" "));
555
+ return true;
556
+ },
557
+ browse: handleBrowseCommand,
558
+ info: handleInfoCommand,
559
+ magnet: handleMagnetCommand,
560
+ download: handleDownloadCommand,
561
+ rescan: async (session) => {
562
+ await session.node.refreshShares();
563
+ printStatus(session.node, (msg) => log(session, msg));
564
+ return true;
565
+ },
566
+ save: async (session) => {
567
+ await session.node.save();
568
+ log(session, "saved");
569
+ return true;
570
+ },
571
+ sleep: async (_node, args) => {
572
+ await sleep(Number(args[1] || 0) * 1000);
573
+ return true;
574
+ },
575
+ quit: async (session) => {
576
+ if (session.shutdown) {
577
+ await session.shutdown();
578
+ return false;
579
+ }
580
+ session.rl?.close();
581
+ await session.node.stop();
582
+ return false;
583
+ },
584
+ };
585
+
586
+ async function runCommand(
587
+ session: CliSession,
588
+ line: string,
589
+ ): Promise<boolean> {
590
+ const args = splitArgs(line.trim());
591
+ if (!args.length) return true;
592
+ const rawCommand = args[0].toLowerCase();
593
+ const command = COMMAND_ALIASES[rawCommand] || rawCommand;
594
+ const handler = COMMAND_HANDLERS[command];
595
+ if (!handler) throw new Error(`unknown command: ${rawCommand}`);
596
+ return await handler(session, args);
597
+ }
598
+
599
+ function startRepl(
600
+ session: CliSession,
601
+ execCmds: string[],
602
+ ): readline.Interface | null {
603
+ runExecCommands(
604
+ execCmds,
605
+ (msg) => log(session, msg),
606
+ sleep,
607
+ (cmd) => runCommand(session, cmd),
608
+ errMsg,
609
+ );
610
+ if (!process.stdin.isTTY) return null;
611
+ const rl = readline.createInterface({
612
+ input: process.stdin,
613
+ output: process.stdout,
614
+ prompt: promptText(session),
615
+ });
616
+ session.rl = rl;
617
+ rl.on("line", (line) => {
618
+ void runCommand(session, line)
619
+ .then((keep) => {
620
+ if (keep) redrawPrompt(session);
621
+ })
622
+ .catch((e) => {
623
+ log(session, errMsg(e));
624
+ });
625
+ });
626
+ rl.on("close", () => {
627
+ if (session.rl === rl) session.rl = null;
628
+ stopPromptThrobber(session);
629
+ });
630
+ redrawPrompt(session);
631
+ return rl;
632
+ }
633
+
634
+ export async function main(argv = process.argv.slice(2)) {
635
+ const cli = parseCli(argv, "gnutella.json");
636
+ if (cli.command === "init") {
637
+ const doc = await loadDoc(cli.config);
638
+ await writeDoc(cli.config, doc);
639
+ console.log(path.resolve(cli.config));
640
+ return;
641
+ }
642
+ if (cli.command !== "run")
643
+ throw new Error(`unsupported command ${cli.command}`);
644
+
645
+ const doc = await loadDoc(cli.config);
646
+ const node = new GnutellaServent(cli.config, doc);
647
+ const session = createCliSession(node);
648
+ node.subscribe((event) => handleNodeEvent(session, event));
649
+ setMonitorIgnoreTokens(session, node.config().monitorIgnoreEvents);
650
+
651
+ let shutdownPromise: Promise<void> | null = null;
652
+ const shutdown = async (): Promise<void> => {
653
+ if (shutdownPromise) return await shutdownPromise;
654
+ shutdownPromise = (async () => {
655
+ session.rl?.close();
656
+ let stopFinished = false;
657
+ let stopFailed = false;
658
+ const stopPromise = node
659
+ .stop()
660
+ .then(() => {
661
+ stopFinished = true;
662
+ })
663
+ .catch((e) => {
664
+ stopFailed = true;
665
+ stopFinished = true;
666
+ process.stderr.write(`shutdown error: ${errMsg(e)}\n`);
667
+ });
668
+ await Promise.race([stopPromise, sleep(CLI_SHUTDOWN_TIMEOUT_MS)]);
669
+ if (!stopFinished) {
670
+ process.stderr.write(
671
+ `shutdown timed out after ${CLI_SHUTDOWN_TIMEOUT_MS}ms; forcing exit\n`,
672
+ );
673
+ }
674
+ process.exit(stopFailed ? 1 : 0);
675
+ })();
676
+ return await shutdownPromise;
677
+ };
678
+ session.shutdown = shutdown;
679
+
680
+ process.on("SIGINT", () => {
681
+ void shutdown();
682
+ });
683
+ process.on("SIGTERM", () => {
684
+ void shutdown();
685
+ });
686
+
687
+ await node.start();
688
+ const rl = startRepl(session, cli.exec);
689
+ rl?.on("close", () => {
690
+ void shutdown();
691
+ });
692
+ }