Zum Inhalt springen
Deutsch

TCP

Zwei Actors, ein Protokoll:

ActorRichtungBesitzt
TcpSocketActorausgehend — wählt einen Remote-Hosteine Verbindung
TcpServerActoreingehend — bindet einen lokalen Portden Listener + jede angenommene Verbindung

Beide erben von BrokerActor, teilen sich also Lifecycle, Reconnect-Policy und die BrokerConnected- / BrokerDisconnected-Events, und beide schneiden eingehende Bytes mit denselben Framing-Strategien.

import { ActorSystem, TcpSocketActor, TcpSocketOptions } from 'actor-ts';
const tcpSocketOptions = TcpSocketOptions.create()
.withHost('metrics-collector.example.com')
.withPort(8125)
.withTarget(protocolHandler); // erforderlich: wohin eingehende Frames gehen
const tcp = system.spawn(() => new TcpSocketActor(tcpSocketOptions), 'tcp-client');
// Rohe Bytes senden:
tcp.tell({ kind: 'send', payload: new Uint8Array([0x01, 0x02, 0x03]) });
// Oder ein String (UTF-8-kodiert für dich):
tcp.tell({ kind: 'send', payload: 'PING\n' });
interface TcpSocketOptionsType extends BrokerCommonOptionsType {
host?: string; // erforderlich
port?: number; // erforderlich
framing?: TcpFraming; // Frame-Extraktion; Default { kind: 'bytes' }
target?: ActorRef<unknown>; // erforderlich: wohin eingehende Frames gehen
}

Eingehende Frames werden direkt an den target-Actor gepusht, den du über .withTarget(..) verdrahtest — es gibt kein subscribe-Kommando und keinen Envelope. Jede Nachricht ist der Frame: ein Uint8Array bei bytes- / length-prefixed- Framing, ein string bei lines.

class ProtocolHandler extends Actor<Uint8Array> {
override onReceive(frame: Uint8Array): void {
this.handleBytes(frame);
}
}

Ohne Framer ({ kind: 'bytes' }, der Default) ist jede Nachricht ein roher Chunk Bytes — KEINE logische Nachricht. TCP ist ein Byte-Stream; Framing ist dein Job.

Der Verbindungs-Lifecycle wird nicht an das Target geliefert — er wird auf system.eventStream als BrokerConnected- / BrokerDisconnected-Events veröffentlicht, die alle Broker-Actors teilen:

import { BrokerConnected, BrokerDisconnected } from 'actor-ts';
system.eventStream.subscribe(monitorRef, BrokerConnected);
system.eventStream.subscribe(monitorRef, BrokerDisconnected);
import { TcpSocketActor, TcpSocketOptions } from 'actor-ts';
const tcpSocketOptions = TcpSocketOptions.create()
.withHost(host)
.withPort(port)
.withFraming({ kind: 'length-prefixed' })
.withTarget(subscriber);
new TcpSocketActor(tcpSocketOptions);

Framing wählst du über .withFraming(..) mit einer TcpFraming-Config-Union — es gibt keine Framer-Klassen zum Instanziieren. Drei Strategien werden mitgeliefert:

type TcpFraming =
| { kind: 'bytes' } // Default — rohe Chunks
| { kind: 'lines'; delimiter?: string; maxLineLen?: number }
| { kind: 'length-prefixed'; maxFrameLen?: number };
  • bytes (Default) — jeder Chunk wird roh geliefert; das Target behandelt die Byte-Stream-Semantik selbst.
  • lines — split am delimiter (Default '\n'); jeder Frame kommt als string an. maxLineLen begrenzt eine nicht terminierte Zeile.
  • length-prefixed — die ersten 4 Bytes (Big-Endian uint32) tragen die Payload-Größe; die Prefix-Breite ist fest, nur maxFrameLen ist konfigurierbar.

Mit lines oder length-prefixed ist jede Nachricht, die das Target empfängt, ein vollständiger Frame, kein beliebiger Chunk.

Beide Actors lesen dieselben Strategien aus demselben Code — auf TcpServerActor wird das gewählte Framing pro angenommener Verbindung angewandt, jede mit eigenem Re-Assembly-Buffer, sodass zwei Clients mitten im Frame nie ineinanderlaufen.

TcpServerActor bindet einen Port und bedient jede eingehende Verbindung. Er baut auf derselben runtime-übergreifenden TCP-Schicht auf wie der Cluster-Transport — Bun, Node und Deno, und TLS, kommen also aus einer Hand.

import { ActorSystem, TcpServerActor, TcpServerOptions } from 'actor-ts';
const tcpServerOptions = TcpServerOptions.create()
.withBindHost('0.0.0.0')
.withBindPort(9000)
.withFraming({ kind: 'lines' })
.withTarget(connectionHandler); // erforderlich: wohin Events + Frames gehen
const server = system.spawn(() => new TcpServerActor(tcpServerOptions), 'tcp-server');
// An genau eine Verbindung schreiben, adressiert über die erhaltene Id:
server.tell({ kind: 'send', connectionId, payload: 'PONG\n' });
// Eine Verbindung auflegen. Der Listener bedient den Rest weiter:
server.tell({ kind: 'close', connectionId });
interface TcpServerOptionsType extends BrokerCommonOptionsType {
bindHost?: string; // Default '0.0.0.0'
bindPort?: number; // erforderlich; 0 = das OS wählt
framing?: TcpFraming; // pro Verbindung; Default { kind: 'bytes' }
target?: ActorRef<TcpServerMessage>; // erforderlich
tls?: TlsTransportOptionsType; // TLS statt Klartext ausliefern
maxConnections?: number; // Aufnahme-Limit; Default Infinity
}

Mit bindPort: 0 wählt das OS den Port — lies ihn über boundPort des Actors zurück, sobald gebunden ist. connectionCount meldet die aktiven Verbindungen.

Das target empfängt eine kind-getaggte Union, keine nackten Frames — ein Listener hat viele Verbindungen, also benennt jede Nachricht die, zu der sie gehört:

type TcpServerMessage =
| { kind: 'connectionOpened'; connectionId: string; remoteAddress?: string }
| { kind: 'frame'; connectionId: string; payload: Uint8Array | string }
| { kind: 'connectionClosed'; connectionId: string };

Die Union und jede ihrer Varianten sind importierbar, ein Handler kann also die Variante nehmen, um die es ihm geht, statt die Union in jedem Arm einzuengen:

import type { FrameMessage, TcpServerCommand, TcpServerMessage } from 'actor-ts';

Ein Echo-Server sind dann die naheliegenden drei Zeilen:

class EchoHandler extends Actor<TcpServerMessage> {
constructor(private readonly server: ActorRef<TcpServerCommand>) { super(); }
override onReceive(message: TcpServerMessage): void {
match(message)
.with({ kind: 'connectionOpened' }, (m) => this.onConnectionOpened(m))
.with({ kind: 'frame' }, (m) => this.onFrame(m))
.with({ kind: 'connectionClosed' }, (m) => this.onConnectionClosed(m))
.exhaustive();
}
private onFrame(message: FrameMessage): void {
this.server.tell({ kind: 'send', connectionId: message.connectionId, payload: message.payload });
}
// …
}

connectionClosed kommt bei jedem Ende: der Peer hat aufgelegt, die Verbindung ist gescheitert, du hast close geschickt, ein Frame hat sein Größenlimit gerissen, oder der Actor wurde gestoppt und hat den Port freigegeben. Ein Signal, ein Codepfad.

tls trägt das Zertifikats-Material — PEM-Inhalt oder DER-Bytes, niemals einen Pfad. Ein gesetztes ca schaltet die Prüfung von Client-Zertifikaten (mTLS) ein; requestClientCert: false erzwingt einseitiges TLS.

const tcpServerOptions = TcpServerOptions.create()
.withBindPort(9443)
.withTls({ cert: readFileSync('server.pem', 'utf8'), key: readFileSync('server.key', 'utf8') })
.withTarget(connectionHandler);

Ein halb konfiguriertes Credential — ein cert ohne key, oder ein ca allein — wird beim Start des Actors abgelehnt, nicht still im Klartext gebunden. Für tls gibt es bewusst kein HOCON-Leaf: eine Config-Datei ist der falsche Ort für einen privaten Schlüssel.

  • maxConnections begrenzt die gleichzeitig angenommenen Verbindungen. Eine Verbindung, die am Limit ankommt, wird sofort abgebrochen statt registriert — Abweisen an der Tür, statt einen Socket anzunehmen, aus dem niemand liest. Abgebrochen, nicht ordentlich beendet: Ein geordnetes Schließen schickt nur ein FIN, und ein Peer, der es nie beantwortet, hält den Socket samt Dateideskriptor offen. Registriert wurde dieser Socket nie, das Limit zählt ihn also auch nicht — ein Halbschluss würde nur kooperierende Peers begrenzen. Der abgewiesene Peer sieht deshalb einen Verbindungsfehler statt eines sauberen Schließens; genau so sieht auf TCP-Ebene aus, an der Kapazitätsgrenze abgewiesen zu werden.
  • Ein Frame über dem framing-Limit trennt nur diese Verbindung. Beim Client-Actor legt derselbe Verstoß den ganzen Actor still, denn dort ist die Verbindung der gesamte Transport; ein Listener, der das genauso hielte, ließe jeden einzelnen Client den Dienst für alle abschalten.
  • outboundBuffer ist hier 0 — anders als bei jedem anderen Broker-Actor. Puffern im getrennten Zustand existiert, damit eine Nachricht einen Reconnect überlebt — aber “getrennt” heißt beim Listener, dass der Port unten ist, und damit ist jede angenommene Verbindung ohnehin weg: ein gepufferter Write benennt eine Id, die nie wiederkommt. Ein BrokerNotConnected-Event sagt das, ein Replay nicht.

Drei legitime Einsatzfälle:

  1. Mit Legacy-Protokollen sprechen — proprietäre Protokolle, für die es keine höherrangigen Wrapper gibt.
  2. Eigene Binärprotokolle — Game-Server, Metric-Collector mit eigenem Wire-Format.
  3. Brücken zu Nicht-HTTP-Services — Message-Queues mit proprietärem Wire (z. B. einige Finanzprotokolle).

Für neue Anwendungsprotokolle gilt: greife nicht zuerst zu raw TCP. HTTP, WebSocket oder gRPC sind für fast alles die besseren Startpunkte.

  • I/O-Übersicht — das große Bild.
  • BrokerActor-Basis — der gemeinsame Lifecycle.
  • UDP — die verbindungslose Alternative.
  • gRPC — typisierter RPC über HTTP/2 — meist die bessere Wahl als raw TCP.