TCP
Zwei Actors, ein Protokoll:
| Actor | Richtung | Besitzt |
|---|---|---|
TcpSocketActor | ausgehend — wählt einen Remote-Host | eine Verbindung |
TcpServerActor | eingehend — bindet einen lokalen Port | den 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.
Hinauswählen — TcpSocketActor
Abschnitt betitelt „Hinauswählen — TcpSocketActor“import { ActorSystem, TcpSocketActor, TcpSocketOptions } from 'actor-ts';
const tcpSocketOptions = TcpSocketOptions.create() .withHost('metrics-collector.example.com') .withPort(8125) .withTarget(protocolHandler); // erforderlich: wohin eingehende Frames gehenconst 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' });Settings
Abschnitt betitelt „Settings“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 Nachrichten
Abschnitt betitelt „Eingehende Nachrichten“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);Framing
Abschnitt betitelt „Framing“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 amdelimiter(Default'\n'); jeder Frame kommt alsstringan.maxLineLenbegrenzt eine nicht terminierte Zeile.length-prefixed— die ersten 4 Bytes (Big-Endianuint32) tragen die Payload-Größe; die Prefix-Breite ist fest, nurmaxFrameLenist 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.
Lauschen — TcpServerActor
Abschnitt betitelt „Lauschen — TcpServerActor“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 gehenconst 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 });Settings
Abschnitt betitelt „Settings“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.
Eingehende Nachrichten
Abschnitt betitelt „Eingehende Nachrichten“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 ausliefern
Abschnitt betitelt „TLS ausliefern“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.
Grenzen
Abschnitt betitelt „Grenzen“maxConnectionsbegrenzt 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. outboundBufferist hier0— 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. EinBrokerNotConnected-Event sagt das, ein Replay nicht.
Wann TCP
Abschnitt betitelt „Wann TCP“Drei legitime Einsatzfälle:
- Mit Legacy-Protokollen sprechen — proprietäre Protokolle, für die es keine höherrangigen Wrapper gibt.
- Eigene Binärprotokolle — Game-Server, Metric-Collector mit eigenem Wire-Format.
- 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.
Wohin als Nächstes
Abschnitt betitelt „Wohin als Nächstes“- 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.
