TCP
このコンテンツはまだ日本語訳がありません。
Two actors, one protocol:
| Actor | Direction | Owns |
|---|---|---|
TcpSocketActor | outbound — dials a remote host | one connection |
TcpServerActor | inbound — binds a local port | the listener + every connection it accepts |
Both extend BrokerActor, so they share the
lifecycle, the reconnect policy and the BrokerConnected /
BrokerDisconnected events, and both cut inbound bytes with the same
framing strategies.
Dialing out — TcpSocketActor
Section titled “Dialing out — TcpSocketActor”import { ActorSystem, TcpSocketActor, TcpSocketOptions } from 'actor-ts';
const tcpSocketOptions = TcpSocketOptions.create() .withHost('metrics-collector.example.com') .withPort(8125) .withTarget(protocolHandler); // required: where inbound frames goconst tcp = system.spawn(() => new TcpSocketActor(tcpSocketOptions), 'tcp-client');
// Send raw bytes:tcp.tell({ kind: 'send', payload: new Uint8Array([0x01, 0x02, 0x03]) });
// Or a string (UTF-8 encoded for you):tcp.tell({ kind: 'send', payload: 'PING\n' });Settings
Section titled “Settings”interface TcpSocketOptionsType extends BrokerCommonOptionsType { host?: string; // required port?: number; // required framing?: TcpFraming; // frame extraction; default { kind: 'bytes' } target?: ActorRef<unknown>; // required: where inbound frames go}Inbound messages
Section titled “Inbound messages”Inbound frames are pushed straight to the target actor you wire
in through .withTarget(..) — there is no subscribe command and
no envelope. Each message is the frame: a Uint8Array for
bytes / length-prefixed framing, a string for lines.
class ProtocolHandler extends Actor<Uint8Array> { override onReceive(frame: Uint8Array): void { this.handleBytes(frame); }}Without a framer ({ kind: 'bytes' }, the default) each message is
a raw chunk of bytes — NOT a logical message. TCP is a byte
stream; framing is your job.
Connection lifecycle is not delivered to the target — it is
published on system.eventStream as BrokerConnected /
BrokerDisconnected events, shared by every broker actor:
import { BrokerConnected, BrokerDisconnected } from 'actor-ts';
system.eventStream.subscribe(monitorRef, BrokerConnected);system.eventStream.subscribe(monitorRef, BrokerDisconnected);Framing
Section titled “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 is chosen through .withFraming(..) with a TcpFraming
config union — there are no framer classes to instantiate. Three
strategies ship:
type TcpFraming = | { kind: 'bytes' } // default — raw chunks | { kind: 'lines'; delimiter?: string; maxLineLen?: number } | { kind: 'length-prefixed'; maxFrameLen?: number };bytes(default) — every chunk delivered raw; the target handles byte-stream semantics itself.lines— split ondelimiter(default'\n'); each frame arrives as astring.maxLineLencaps an un-terminated line.length-prefixed— the first 4 bytes (big-endianuint32) carry the payload size; the prefix width is fixed, onlymaxFrameLenis configurable.
With lines or length-prefixed, each message the target receives
is one full frame, not an arbitrary chunk.
Both actors read the same strategies from the same code — on
TcpServerActor the chosen framing is applied per accepted
connection, each with its own re-assembly buffer, so two clients
mid-frame never splice into one another.
Listening — TcpServerActor
Section titled “Listening — TcpServerActor”TcpServerActor binds a port and serves every connection that
arrives. It is built on the same cross-runtime TCP layer the
cluster transport uses, so Bun, Node and Deno — and TLS — come
from one place.
import { ActorSystem, TcpServerActor, TcpServerOptions } from 'actor-ts';
const tcpServerOptions = TcpServerOptions.create() .withBindHost('0.0.0.0') .withBindPort(9000) .withFraming({ kind: 'lines' }) .withTarget(connectionHandler); // required: where events + frames goconst server = system.spawn(() => new TcpServerActor(tcpServerOptions), 'tcp-server');
// Write to one connection, addressed by the id you were handed:server.tell({ kind: 'send', connectionId, payload: 'PONG\n' });
// Hang up on one connection. The listener keeps serving the rest:server.tell({ kind: 'close', connectionId });Settings
Section titled “Settings”interface TcpServerOptionsType extends BrokerCommonOptionsType { bindHost?: string; // default '0.0.0.0' bindPort?: number; // required; 0 = let the OS pick framing?: TcpFraming; // per connection; default { kind: 'bytes' } target?: ActorRef<TcpServerMessage>; // required tls?: TlsTransportOptionsType; // serve TLS instead of plaintext maxConnections?: number; // admission cap; default Infinity}With bindPort: 0 the OS picks the port — read it back from the
actor’s boundPort once it is bound. connectionCount reports
the live connections.
Inbound messages
Section titled “Inbound messages”The target receives a kind-tagged union, not bare frames —
a listener has many connections, so every message names the one
it belongs to:
type TcpServerMessage = | { kind: 'connectionOpened'; connectionId: string; remoteAddress?: string } | { kind: 'frame'; connectionId: string; payload: Uint8Array | string } | { kind: 'connectionClosed'; connectionId: string };The union and each of its variants are importable, so a handler can take the variant it is about rather than narrowing the union at every arm:
import type { FrameMessage, TcpServerCommand, TcpServerMessage } from 'actor-ts';An echo server is then the obvious three lines:
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 arrives for every ending: the peer hung up, the
connection errored, you sent close, a frame breached its size cap,
or the actor stopped and unbound the port. One signal, one code path.
Serving TLS
Section titled “Serving TLS”tls carries the certificate material — PEM contents or DER
bytes, never a path. Supplying ca turns on client-certificate
verification (mTLS); set requestClientCert: false for one-way TLS.
const tcpServerOptions = TcpServerOptions.create() .withBindPort(9443) .withTls({ cert: readFileSync('server.pem', 'utf8'), key: readFileSync('server.key', 'utf8') }) .withTarget(connectionHandler);A half-configured credential — a cert with no key, or a ca
alone — is rejected when the actor starts, not quietly bound in
plaintext. There is deliberately no HOCON leaf for tls: a config
file is the wrong place for a private key.
Bounds
Section titled “Bounds”maxConnectionscaps simultaneously accepted connections. A connection arriving at the cap is aborted immediately instead of being registered — refusing at the door, rather than accepting a socket nobody reads from. Aborted, not ended: an orderly close only sends a FIN, and a peer that never answers it keeps the socket, and its file descriptor, alive. Since that socket was never registered the cap does not count it either, so a half-close would bound only peers that cooperate. The refused peer therefore sees a connection error rather than a clean close — which is what being turned away at capacity looks like at the TCP level.- A frame past its
framingcap drops only that connection. On the client actor the same breach takes the whole actor down, because there it is the whole transport; a listener that did the same would let any single client take the service down for everyone. outboundBufferdefaults to0here, unlike every other broker actor. Buffering while disconnected exists so a message survives a reconnect — but “disconnected” for a listener means the port is down, which means every connection it accepted is already gone, so a buffered write names an id that can never come back. ABrokerNotConnectedevent says that; a replay would not.
When to use TCP
Section titled “When to use TCP”Three legitimate uses:
- Talking to legacy protocols — proprietary protocols that don’t have higher-level wrappers.
- Custom binary protocols — game servers, metric collectors with custom wire formats.
- Bridging to non-HTTP services — message queues with proprietary wire (some financial protocols, e.g.).
For new application protocols, don’t reach for raw TCP first. HTTP, WebSocket, or gRPC are better starting points for almost everything.
Where to next
Section titled “Where to next”- I/O overview — the bigger picture.
- BrokerActor base — the shared lifecycle.
- UDP — the connectionless alternative.
- gRPC — typed RPC over HTTP/2 — usually the better choice than raw TCP.
