feat(core): Gnutella joining after Java, measured against Java
GnutellaRun lets Java's PeerToPeerAnwendung join peers on terminal-net. The core's gnutella.ts does the same: a ping in Java's text format to port 6346, a pong back, the ping sent on to the other peers and their pongs back the way it came, at most the program's number of peers. Departure from Java: a ping that comes a second time gets no pong and its connection stays (Java's worker throws on the missing pong). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
// FILIUS' Gnutella program (filius.software.dateiaustausch: PeerToPeerAnwendung
|
||||
// with its PeerToPeerServer on port 6346 and PeerToPeerClient): a host joins
|
||||
// the network by a ping to one peer, which answers with a pong, takes the
|
||||
// host as a peer and sends the ping on to its own peers, whose pongs come
|
||||
// back the same way; each knows at most its number of peers. The messages
|
||||
// are Java's text packets (PeerToPeerPaket.toString), each on a connection
|
||||
// of its own that stays open, as Java's do.
|
||||
//
|
||||
// Departures from Java (gnutellaJava.test.ts names each):
|
||||
// - a ping that comes a second time gets no pong, and its connection stays
|
||||
// (Java means so, but its worker throws on the missing pong and closes the
|
||||
// connection).
|
||||
|
||||
import type { Id } from '../../ids.js';
|
||||
import type { GnutellaConfiguration } from '../config.js';
|
||||
import type { NetworkRuntimeEngine } from '../engine.js';
|
||||
import type { Steps } from '../threads.js';
|
||||
import { ProgramLog } from './program.js';
|
||||
import { TcpServerProgram } from './server.js';
|
||||
|
||||
/** PeerToPeerServer, PeerToPeerClientMitarbeiter: Gnutella's port. */
|
||||
export const GNUTELLA_PORT = 6346;
|
||||
|
||||
/** Where the program keeps its files to share (PeerToPeerAnwendung.setSystemSoftware). */
|
||||
export const GNUTELLA_FOLDER = '/peer2peer';
|
||||
|
||||
/** A packet's head (PeerToPeerPaket): its id, kind, hops, time to live and payload length in bits. */
|
||||
interface GnutellaHead {
|
||||
readonly guid: number;
|
||||
readonly hops: number;
|
||||
readonly ttl: number;
|
||||
readonly payloadLength: number;
|
||||
}
|
||||
|
||||
/** PingPaket: from the host with this address. */
|
||||
export interface PingPacket extends GnutellaHead {
|
||||
readonly kind: 'ping';
|
||||
readonly ipAddress: string;
|
||||
}
|
||||
|
||||
/** PongPaket: the answering host, its port, its files and their bytes (Java calls them KBs). */
|
||||
export interface PongPacket extends GnutellaHead {
|
||||
readonly kind: 'pong';
|
||||
readonly ipAddress: string;
|
||||
readonly port: number;
|
||||
readonly files: number;
|
||||
readonly bytes: number;
|
||||
}
|
||||
|
||||
export type GnutellaPacket = PingPacket | PongPacket;
|
||||
|
||||
const PAYLOAD_KIND = { ping: '0x00', pong: '0x01' } as const;
|
||||
|
||||
/** PeerToPeerPaket.anzahlBenoetigterBits: the least exponent whose power of two is at least the number. */
|
||||
function bitsFor(value: number): number {
|
||||
let exponent = 0;
|
||||
while (2 ** exponent < value) exponent += 1;
|
||||
return exponent;
|
||||
}
|
||||
|
||||
/** PongPaket.payloadLengthBerechnen. */
|
||||
export function pongPayloadLength(ipAddress: string, port: number, files: number, bytes: number): number {
|
||||
return ipAddress.length * 8 + bitsFor(port) + bitsFor(files) + bitsFor(bytes);
|
||||
}
|
||||
|
||||
/** PeerToPeerPaket.toString and its subclasses': the fields joined by "//". */
|
||||
export function gnutellaText(packet: GnutellaPacket): string {
|
||||
const head = [packet.guid, PAYLOAD_KIND[packet.kind], packet.hops, packet.ttl, packet.payloadLength];
|
||||
const body = packet.kind === 'ping' ? [packet.ipAddress] : [packet.ipAddress, packet.port, packet.files, packet.bytes];
|
||||
return [...head, ...body].join('//');
|
||||
}
|
||||
|
||||
/** A packet's fields, as Java's constructors read them: a StringTokenizer on "/", empty fields skipped. */
|
||||
const fieldsOf = (text: string) => text.split('/').filter((field) => field !== '');
|
||||
|
||||
const numberAt = (fields: readonly string[], index: number) => (/^-?\d+$/.test(fields[index] ?? '') ? Number(fields[index]) : undefined);
|
||||
|
||||
/** PeerToPeerPaket(String): whether the text has a packet's head (its id, kind, hops, time to live, payload length). */
|
||||
export function hasGnutellaHead(text: string): boolean {
|
||||
const fields = fieldsOf(text);
|
||||
return fields[1] !== undefined && [0, 2, 3, 4].every((index) => numberAt(fields, index) !== undefined);
|
||||
}
|
||||
|
||||
/** A packet from its text; undefined for any other kind or a text that is no packet. */
|
||||
export function gnutellaPacket(text: string): GnutellaPacket | undefined {
|
||||
const fields = fieldsOf(text);
|
||||
const number = (index: number) => numberAt(fields, index);
|
||||
const [guid, hops, ttl, payloadLength] = [number(0), number(2), number(3), number(4)];
|
||||
if (guid === undefined || hops === undefined || ttl === undefined || payloadLength === undefined) return undefined;
|
||||
const head = { guid, hops, ttl, payloadLength };
|
||||
if (fields[1] === PAYLOAD_KIND.ping && fields[5] !== undefined) return { kind: 'ping', ...head, ipAddress: fields[5] };
|
||||
const [port, files, bytes] = [number(6), number(7), number(8)];
|
||||
if (fields[1] === PAYLOAD_KIND.pong && fields[5] !== undefined && port !== undefined && files !== undefined && bytes !== undefined) {
|
||||
return { kind: 'pong', ...head, ipAddress: fields[5], port, files, bytes };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** What the program tells its window: its known peers, whenever they change. */
|
||||
export type GnutellaLogEntry = { readonly kind: 'peers'; readonly peers: readonly string[] };
|
||||
|
||||
/** PeerToPeerServer: answers pings on 6346; a connection's worker remembers the id of its last packet. */
|
||||
class GnutellaServer extends TcpServerProgram {
|
||||
/** Each connection's last packet id (PeerToPeerServerMitarbeiter.guid), to send answers back on. */
|
||||
private readonly lastGuidBySocket = new Map<Id, number>();
|
||||
|
||||
constructor(
|
||||
engine: NetworkRuntimeEngine,
|
||||
nodeId: Id,
|
||||
private readonly peer: GnutellaPeer,
|
||||
) {
|
||||
super(engine, nodeId, { port: GNUTELLA_PORT, isActive: true });
|
||||
}
|
||||
|
||||
/** Never asked: each connection runs its own dialogue (converse). */
|
||||
protected answer(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
protected override *converse(socketId: Id, address: string): Steps {
|
||||
for (;;) {
|
||||
const received = yield* this.engine.receiveTcpSteps(socketId);
|
||||
if (received.type !== 'message') {
|
||||
this.lastGuidBySocket.delete(socketId);
|
||||
return;
|
||||
}
|
||||
const text = new TextDecoder().decode(received.payload);
|
||||
// PeerToPeerServerMitarbeiter.verarbeiteNachricht: a text without a packet's head throws, and the worker closes the
|
||||
// connection; a packet of another kind is left.
|
||||
if (!hasGnutellaHead(text)) {
|
||||
this.lastGuidBySocket.delete(socketId);
|
||||
return;
|
||||
}
|
||||
const packet = gnutellaPacket(text);
|
||||
if (packet === undefined) continue;
|
||||
this.lastGuidBySocket.set(socketId, packet.guid);
|
||||
if (packet.kind === 'ping') yield* this.peer.answerPing(packet, address, (text) => this.engine.sendTcpSteps(socketId, new TextEncoder().encode(text)));
|
||||
}
|
||||
}
|
||||
|
||||
/** PeerToPeerServer.sendePaket: on every connection whose last packet had this id. */
|
||||
sendBack(packet: GnutellaPacket): void {
|
||||
for (const [socketId, guid] of this.lastGuidBySocket) {
|
||||
if (guid !== packet.guid) continue;
|
||||
const engine = this.engine;
|
||||
engine.startThread(
|
||||
(function* (): Steps {
|
||||
yield* engine.sendTcpSteps(socketId, new TextEncoder().encode(gnutellaText(packet)));
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GnutellaPeer {
|
||||
readonly log: ProgramLog<GnutellaLogEntry>;
|
||||
private peersValue: string[] = [];
|
||||
private readonly maximumKnownPeers: number;
|
||||
/** The ids of the program's own pings (eigeneAnfragen), of others' (fremdeAnfragen), and of those it sent on (schonmalVerschicktListe). */
|
||||
private readonly ownRequests = new Set<number>();
|
||||
private readonly foreignRequests = new Set<number>();
|
||||
private readonly alreadySent = new Set<number>();
|
||||
private readonly server: GnutellaServer;
|
||||
|
||||
constructor(
|
||||
private readonly engine: NetworkRuntimeEngine,
|
||||
private readonly nodeId: Id,
|
||||
configuration: GnutellaConfiguration,
|
||||
) {
|
||||
this.log = new ProgramLog(engine);
|
||||
this.maximumKnownPeers = configuration.maximumKnownPeers;
|
||||
this.server = new GnutellaServer(engine, nodeId, this);
|
||||
}
|
||||
|
||||
/** The known peers (holeBekanntePeerToPeerTeilnehmer), in the order they came. */
|
||||
get peers(): readonly string[] {
|
||||
return this.peersValue;
|
||||
}
|
||||
|
||||
/** The node's first address (Betriebssystem.primaryIPAdresse). */
|
||||
private get ownAddress(): string {
|
||||
return this.engine.networkInterfaces(this.nodeId)[0]?.ipAddress ?? '';
|
||||
}
|
||||
|
||||
/** beitretenNetzwerk: a ping to the peer at the address, not to the host itself. */
|
||||
join(address: string): void {
|
||||
if (address === this.ownAddress) return;
|
||||
const ping: PingPacket = { kind: 'ping', guid: this.engine.drawGnutellaGuid(), hops: 0, ttl: 8, payloadLength: 0, ipAddress: this.ownAddress };
|
||||
this.ownRequests.add(ping.guid);
|
||||
this.engine.startThread(this.sendTo(address, ping));
|
||||
}
|
||||
|
||||
/** resetNetwork: no known peers. */
|
||||
reset(): void {
|
||||
this.peersValue = [];
|
||||
this.log.write({ kind: 'peers', peers: [] });
|
||||
}
|
||||
|
||||
/** hinzuTeilnehmer: a new peer while there is room. */
|
||||
private addPeer(address: string): void {
|
||||
if (this.peersValue.includes(address) || this.peersValue.length >= this.maximumKnownPeers) return;
|
||||
this.peersValue = [...this.peersValue, address];
|
||||
this.log.write({ kind: 'peers', peers: this.peersValue });
|
||||
}
|
||||
|
||||
/**
|
||||
* PeerToPeerServerMitarbeiter.verarbeitePing: the sender's host taken as a
|
||||
* peer, a pong unless the ping was here already, and the ping sent on to
|
||||
* the other peers.
|
||||
*/
|
||||
*answerPing(ping: PingPacket, from: string, send: (text: string) => Steps<unknown>): Steps {
|
||||
this.addPeer(ping.ipAddress);
|
||||
// erstellePong: none for a ping sent on before.
|
||||
if (!this.alreadySent.has(ping.guid)) {
|
||||
const files = this.sharedFiles();
|
||||
const bytes = files.reduce((sum, size) => sum + size, 0);
|
||||
const address = this.ownAddress;
|
||||
const pong: PongPacket = {
|
||||
kind: 'pong',
|
||||
guid: ping.guid,
|
||||
hops: 0,
|
||||
ttl: 8,
|
||||
payloadLength: pongPayloadLength(address, GNUTELLA_PORT, files.length, bytes),
|
||||
ipAddress: address,
|
||||
port: GNUTELLA_PORT,
|
||||
files: files.length,
|
||||
bytes,
|
||||
};
|
||||
yield* send(gnutellaText(pong));
|
||||
}
|
||||
this.sendPingOn({ ...ping, hops: ping.hops + 1, ttl: ping.ttl - 1 }, from);
|
||||
}
|
||||
|
||||
/** PeerToPeerAnwendung.sendePing: once per id, while it lives, to each peer but the sender and the ping's host. */
|
||||
private sendPingOn(ping: PingPacket, from: string): void {
|
||||
if (ping.ttl <= 0 || this.alreadySent.has(ping.guid)) return;
|
||||
this.alreadySent.add(ping.guid);
|
||||
this.foreignRequests.add(ping.guid);
|
||||
for (const peer of this.peersValue) {
|
||||
if (peer !== from && peer !== ping.ipAddress) this.engine.startThread(this.sendTo(peer, ping));
|
||||
}
|
||||
}
|
||||
|
||||
/** verarbeitePong: the program's own ping answered (a new peer), else the pong sent back the way its ping came. */
|
||||
private takePong(pong: PongPacket): void {
|
||||
if (this.ownRequests.has(pong.guid)) this.addPeer(pong.ipAddress);
|
||||
else this.server.sendBack({ ...pong, ttl: pong.ttl - 1, hops: pong.hops + 1 });
|
||||
}
|
||||
|
||||
/** The sizes of the files in /peer2peer (Datei.holeGroesse, a text in characters). */
|
||||
private sharedFiles(): number[] {
|
||||
const files = this.engine.state.topologySnapshot.fileSystemsByNodeId.get(this.nodeId);
|
||||
if (files === undefined || !files.contains(GNUTELLA_FOLDER)) return [];
|
||||
return files.entriesInCreationOrder(GNUTELLA_FOLDER).flatMap((entry) => {
|
||||
const content = entry.content;
|
||||
return content.kind === 'directory' ? [] : [content.kind === 'text' ? content.text.length : content.data.length];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PeerToPeerClientMitarbeiter.run: a connection to the peer's port 6346, the
|
||||
* packet sent, then each answer taken until the connection ends.
|
||||
*/
|
||||
private *sendTo(address: string, packet: GnutellaPacket): Steps {
|
||||
const engine = this.engine;
|
||||
const socketId = engine.openTcpClientSocket(this.nodeId, address, GNUTELLA_PORT);
|
||||
if (socketId === undefined || (yield* engine.connectTcpSteps(socketId)) !== 'connected') {
|
||||
if (socketId !== undefined) engine.requestTcpClose(socketId);
|
||||
return;
|
||||
}
|
||||
yield* engine.sendTcpSteps(socketId, new TextEncoder().encode(gnutellaText(packet)));
|
||||
for (;;) {
|
||||
const received = yield* engine.receiveTcpSteps(socketId);
|
||||
if (received.type !== 'message') break;
|
||||
const answer = gnutellaPacket(new TextDecoder().decode(received.payload));
|
||||
if (answer?.kind === 'pong') this.takePong(answer);
|
||||
}
|
||||
engine.requestTcpClose(socketId);
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,7 @@ import { firewallLogPage, forwardingTablePage, ripTablePage, routerIndexPage } f
|
||||
import { applyFirewallSettings, firewallSettingsPage } from './apps/firewallSettings.js';
|
||||
import { parentPath, VirtualFileSystem } from '../model/virtualFileSystem.js';
|
||||
import { EMAIL_SERVER_ACCOUNTS_FILE, serverAccountsFileText } from './emailAccountsFile.js';
|
||||
import { GNUTELLA_FOLDER, GnutellaPeer } from './apps/gnutella.js';
|
||||
import {
|
||||
PRODUCTION_TRACE_RETENTION,
|
||||
type HeaderField,
|
||||
@@ -882,6 +883,8 @@ export class NetworkRuntimeEngine {
|
||||
private dnsIdRandom: JavaCompatibleRandom;
|
||||
/** Draws the command line's first ICMP identifier (Terminal.setIdentifier: new Random()). */
|
||||
private icmpIdentifierRandom: JavaCompatibleRandom;
|
||||
/** Draws Gnutella packet ids (PeerToPeerPaket.guidErstellen: new Random()). */
|
||||
private gnutellaGuidRandom: JavaCompatibleRandom;
|
||||
/** Nodes whose ARP table lost its broadcast row (arp -d 255.255.255.255), until the table is reset. */
|
||||
private readonly arpBroadcastRowRemoved = new Set<Id>();
|
||||
private runtimeSpeed: RuntimeSpeed;
|
||||
@@ -930,6 +933,7 @@ export class NetworkRuntimeEngine {
|
||||
private readonly emailServersByNodeId = new Map<Id, EmailServer>();
|
||||
private readonly emailClientsByNodeId = new Map<Id, EmailClient>();
|
||||
private readonly terminalsByNodeId = new Map<Id, Terminal>();
|
||||
private readonly gnutellaPeersByNodeId = new Map<Id, GnutellaPeer>();
|
||||
/** The time of day a program writes into a mail (Java's new Date()). */
|
||||
private readonly wallClock: () => number;
|
||||
/** Each node's firewall log; it outlives a stop and a new start, as Java's log page does. */
|
||||
@@ -981,6 +985,7 @@ export class NetworkRuntimeEngine {
|
||||
this.linkLocalRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x4c4c_4144n);
|
||||
this.dnsIdRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x444e_5349n);
|
||||
this.icmpIdentifierRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x4943_4d50n);
|
||||
this.gnutellaGuidRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x474e_5554n);
|
||||
this.runtimeSpeed = new RuntimeSpeed();
|
||||
this.packetTraceRetentionPolicy = packetTraceRetentionPolicy;
|
||||
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
|
||||
@@ -1035,6 +1040,7 @@ export class NetworkRuntimeEngine {
|
||||
this.linkLocalRandom = new JavaCompatibleRandom(seed ^ 0x4c4c_4144n);
|
||||
this.dnsIdRandom = new JavaCompatibleRandom(seed ^ 0x444e_5349n);
|
||||
this.icmpIdentifierRandom = new JavaCompatibleRandom(seed ^ 0x4943_4d50n);
|
||||
this.gnutellaGuidRandom = new JavaCompatibleRandom(seed ^ 0x474e_5554n);
|
||||
this.arpBroadcastRowRemoved.clear();
|
||||
this.isProcessingScheduledEvents = false;
|
||||
this.applicationThreads.clear();
|
||||
@@ -1065,6 +1071,7 @@ export class NetworkRuntimeEngine {
|
||||
this.emailServersByNodeId.clear();
|
||||
this.emailClientsByNodeId.clear();
|
||||
this.terminalsByNodeId.clear();
|
||||
this.gnutellaPeersByNodeId.clear();
|
||||
this.arpLookups = [];
|
||||
this.protocolThreads.clear();
|
||||
this.dnsQueriesBySocketId.clear();
|
||||
@@ -1161,6 +1168,19 @@ export class NetworkRuntimeEngine {
|
||||
return this.terminalsByNodeId.get(nodeId);
|
||||
}
|
||||
|
||||
/** The node's Gnutella program while the simulation runs, if it has one installed. */
|
||||
gnutella(nodeId: Id): GnutellaPeer | undefined {
|
||||
return this.gnutellaPeersByNodeId.get(nodeId);
|
||||
}
|
||||
|
||||
/** A Gnutella packet's id (PeerToPeerPaket.guidErstellen: Random.nextInt until it is not negative). */
|
||||
drawGnutellaGuid(): number {
|
||||
for (;;) {
|
||||
const value = this.gnutellaGuidRandom.next(32) | 0;
|
||||
if (value >= 0) return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** The node's e-mail program while the simulation runs, if it has one installed. */
|
||||
emailClient(nodeId: Id): EmailClient | undefined {
|
||||
return this.emailClientsByNodeId.get(nodeId);
|
||||
@@ -1224,6 +1244,7 @@ export class NetworkRuntimeEngine {
|
||||
this.emailServersByNodeId.clear();
|
||||
this.emailClientsByNodeId.clear();
|
||||
this.terminalsByNodeId.clear();
|
||||
this.gnutellaPeersByNodeId.clear();
|
||||
for (const node of snapshot.nodes) {
|
||||
const echoServer = snapshot.echoServersByNodeId.get(node.id);
|
||||
if (echoServer !== undefined) this.echoServersByNodeId.set(node.id, new EchoServer(this, node.id, echoServer));
|
||||
@@ -1251,6 +1272,13 @@ export class NetworkRuntimeEngine {
|
||||
this.writeEmailServerAccountsFile(nodeId, emailServer.domain, emailServer.accounts);
|
||||
}
|
||||
if (snapshot.terminalNodeIds.has(node.id)) this.terminalsByNodeId.set(node.id, new Terminal(this, node.id, this.nodeFiles(node.id)));
|
||||
const gnutella = snapshot.gnutellaByNodeId.get(node.id);
|
||||
if (gnutella !== undefined) {
|
||||
// PeerToPeerAnwendung.setSystemSoftware: the folder of the files to share.
|
||||
const files = this.nodeFiles(node.id);
|
||||
if (!files.contains(GNUTELLA_FOLDER)) files.createDirectory(GNUTELLA_FOLDER);
|
||||
this.gnutellaPeersByNodeId.set(node.id, new GnutellaPeer(this, node.id, gnutella));
|
||||
}
|
||||
const emailClient = snapshot.emailClientsByNodeId.get(node.id);
|
||||
if (emailClient !== undefined) {
|
||||
const nodeId = node.id;
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
WebServerConfiguration,
|
||||
EmailServerAccount,
|
||||
EmailClientConfiguration,
|
||||
GnutellaConfiguration,
|
||||
} from './config.js';
|
||||
import type { VirtualFileSystem } from '../model/virtualFileSystem.js';
|
||||
import type { PageLanguage } from './apps/routerPages.js';
|
||||
@@ -107,6 +108,8 @@ export interface TopologySnapshot {
|
||||
readonly terminalNodeIds: ReadonlySet<Id>;
|
||||
/** The installed e-mail programs, by node; a run's sent and fetched mails change their folders here. */
|
||||
readonly emailClientsByNodeId: ReadonlyMap<Id, EmailClientConfiguration>;
|
||||
/** The installed Gnutella programs (PeerToPeerAnwendung), by node: how many peers each keeps. */
|
||||
readonly gnutellaByNodeId: ReadonlyMap<Id, GnutellaConfiguration>;
|
||||
/** Each node's files, which its programs read (a web server its pages). */
|
||||
readonly fileSystemsByNodeId: ReadonlyMap<Id, VirtualFileSystem>;
|
||||
/** The language of the pages the devices write themselves (a router's), as Java's in the locale it runs in. */
|
||||
@@ -133,6 +136,7 @@ export interface TopologySnapshotInput {
|
||||
webBrowserNodeIds?: ReadonlySet<Id>;
|
||||
emailServersByNodeId?: ReadonlyMap<Id, EmailServerSnapshot>;
|
||||
emailClientsByNodeId?: ReadonlyMap<Id, EmailClientConfiguration>;
|
||||
gnutellaByNodeId?: ReadonlyMap<Id, GnutellaConfiguration>;
|
||||
terminalNodeIds?: ReadonlySet<Id>;
|
||||
fileSystemsByNodeId?: ReadonlyMap<Id, VirtualFileSystem>;
|
||||
language?: PageLanguage;
|
||||
@@ -159,6 +163,7 @@ export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot
|
||||
webBrowserNodeIds: input.webBrowserNodeIds ?? new Set(),
|
||||
emailServersByNodeId: input.emailServersByNodeId ?? new Map(),
|
||||
emailClientsByNodeId: input.emailClientsByNodeId ?? new Map(),
|
||||
gnutellaByNodeId: input.gnutellaByNodeId ?? new Map(),
|
||||
terminalNodeIds: input.terminalNodeIds ?? new Set(),
|
||||
fileSystemsByNodeId: input.fileSystemsByNodeId ?? new Map(),
|
||||
language: input.language ?? 'de',
|
||||
|
||||
Reference in New Issue
Block a user