gRPC
此内容尚不支持你的语言。
The framework provides two actor classes for gRPC:
| Class | Role |
|---|---|
GrpcServerActor | Hosts a gRPC server; exposes service methods. |
GrpcClientActor | Connects to a remote gRPC service; calls methods. |
Both wrap @grpc/grpc-js. Useful when you have Protobuf-typed
contracts and want the broker-actor lifecycle (reconnect,
buffer, subscriber fan-out) for client-side calls.
Both actors are configured with a fluent options builder —
GrpcServerOptions.create()… / GrpcClientOptions.create()… —
and spawned like any other actor.
Server
Section titled “Server”The server is a concrete actor: spawn it directly. It loads the
proto, registers the method handlers, and binds in preStart —
the spawn call returns before the bind completes, so give it a
moment before the first client call.
Each method maps to a handler actor that receives the inbound
call as a message. Handlers are keyed by method name in the
handlers map; any method absent from the map is reported as
UNIMPLEMENTED.
import { Actor, GrpcServerActor, GrpcServerOptions, type GrpcUnaryCall, type GrpcServerStreamCall,} from 'actor-ts';
// A unary handler: reply once via `respond`.class GetSensorHandler extends Actor<GrpcUnaryCall> { override onReceive(call: GrpcUnaryCall): void { const id = (call.request as { id: string }).id; call.respond({ id, label: `sensor-${id}` }); }}
// A server-stream handler: emit chunks via `send`, finish via `complete`.class WatchSensorHandler extends Actor<GrpcServerStreamCall> { override onReceive(call: GrpcServerStreamCall): void { const limit = (call.request as { limit?: number }).limit ?? 5; for (let i = 0; i < limit; i++) { call.send({ value: 20 + i, ts: Date.now() }); } call.complete(); }}
const getHandler = system.spawn(GetSensorHandler, 'get');const watchHandler = system.spawn(WatchSensorHandler, 'watch');
const grpcServerOptions = GrpcServerOptions.create() .withProtoPath(protoPath) .withPackageName('sensor.v1') .withServiceName('SensorService') .withBind('127.0.0.1:50051') .withHandlers({ GetSensor: { kind: 'unary', target: getHandler }, WatchSensor: { kind: 'serverStream', target: watchHandler }, });const server = system.spawn( () => new GrpcServerActor( grpcServerOptions, ), 'grpc-server',);The bind address is a single host:port string via withBind;
packageName and serviceName are set separately. Each handler
descriptor is { kind, target } where kind is 'unary',
'serverStream', 'clientStream', or 'bidi'.
Handler call shapes
Section titled “Handler call shapes”The framework de-serializes the Protobuf payload and hands the handler a typed call object:
| Handler kind | Call type | Reply API |
|---|---|---|
unary | GrpcUnaryCall | respond(res) / respondError(message, code?) |
serverStream | GrpcServerStreamCall | send(chunk) / complete() / fail(message, code?) |
clientStream | GrpcClientStreamCall | onData(target) / respond(res) / respondError(message, code?) |
bidi | GrpcBidiCall | onData(target) / send(chunk) / complete() |
Every call carries method, the deserialized request (except the
client-stream and bidi calls, which stream their requests), and a
metadata record. respondError / fail take an optional numeric
status code that defaults to 13 (INTERNAL).
An onData subscriber receives GrpcRequestStreamInbound — either
{ kind: 'chunk', chunk } or { kind: 'end' }.
Client
Section titled “Client”import { match, P } from 'ts-pattern';import { Actor, GrpcClientActor, GrpcClientOptions, type GrpcInbound, type ReplyMessage, type RpcErrorMessage, type StreamDataMessage, type StreamErrorMessage, type StreamStartedMessage,} from 'actor-ts';
const grpcClientOptions = GrpcClientOptions.create() .withProtoPath(protoPath) .withPackageName('sensor.v1') .withServiceName('SensorService') .withEndpoint('127.0.0.1:50051');const client = system.spawn( () => new GrpcClientActor( grpcClientOptions, ), 'sensor-client',);
// A collector actor receives every reply / stream frame as `GrpcInbound`.class ReplyCollector extends Actor<GrpcInbound> { override onReceive(message: GrpcInbound): void { match(message) .with({ kind: 'reply' }, (m) => this.onReply(m)) .with({ kind: 'stream-started' }, (m) => this.onStreamStarted(m)) .with({ kind: 'stream-data' }, (m) => this.onStreamData(m)) .with({ kind: 'stream-end' }, () => this.onStreamEnd()) // Unary and streaming failures are reported the same way. .with(P.union({ kind: 'rpc-error' }, { kind: 'stream-error' }), (m) => this.onError(m)) .exhaustive(); }
private onReply(message: ReplyMessage): void { console.log('unary reply:', message.response); }
// A client stream is open — `message.handle` addresses its writes. private onStreamStarted(message: StreamStartedMessage): void { console.log('client stream open:', message.handle.streamId); }
private onStreamData(message: StreamDataMessage): void { console.log('stream chunk:', message.chunk); }
private onStreamEnd(): void { console.log('stream complete'); }
private onError(message: RpcErrorMessage | StreamErrorMessage): void { console.error('error:', message.error.message); }}
const collector = system.spawn(ReplyCollector, 'collector');
// Make a unary call — the reply is delivered to `target`.client.tell({ kind: 'unary', method: 'GetSensor', request: { id: 'rt-7' }, target: collector });The endpoint is a single host:port string via withEndpoint.
Every call names a target actor; the actor routes the reply
and any stream frames there. A per-call deadline is configurable
with .withDeadlineMs(30000) (the default).
Inbound frames
Section titled “Inbound frames”Replies and stream frames arrive at the target actor as a
GrpcInbound discriminated union:
kind | Fields | Meaning |
|---|---|---|
reply | response | Unary or client-stream completion. |
stream-started | handle | A client stream is open — see below. |
stream-data | streamId, chunk | One stream chunk. |
stream-end | streamId | Stream closed cleanly. |
stream-error | streamId, error | Stream failed. |
rpc-error | error | Unary / call setup failed. |
Streaming modes
Section titled “Streaming modes”gRPC has four call types, and the framework covers all four:
| Type | Client sends | Server returns | Client-side kinds |
|---|---|---|---|
| Unary | One request | One response | unary |
| Server-streaming | One request | Stream of responses | serverStream |
| Client-streaming | Stream of requests | One response | clientStreamStart / clientStreamSend / clientStreamClose |
| Bi-directional streaming | Stream of requests | Stream of responses | bidiStart / bidiSend / bidiClose |
Each client-side call is a command you tell the client actor; the
kind selects the call type.
// Server-streaming: one request, N chunks routed to `target`.client.tell({ kind: 'serverStream', method: 'WatchSensor', request: { id: 'rt-7', limit: 5 }, target: collector,});
// Client-streaming: open a stream, then push requests into it.client.tell({ kind: 'clientStreamStart', method: 'ReportReadings', target: collector });Client streaming and the stream handle
Section titled “Client streaming and the stream handle”clientStreamStart returns nothing directly. The actor delivers a
stream-started frame to target carrying a GrpcStreamHandle,
and that handle is what addresses every subsequent write:
// Inside the collector, on the `stream-started` frame:const handle = message.handle;
client.tell({ kind: 'clientStreamSend', handle, chunk: { value: 21.5 } });client.tell({ kind: 'clientStreamClose', handle });clientStreamClose half-closes the request stream; the server’s
single response then arrives as an ordinary reply, and a
failure as rpc-error.
The handle has two fields, with two different jobs. streamId is
the correlation id — the same number this stream’s frames carry, so
one collector can multiplex several concurrent streams. token is
the capability: a tell carries no verified sender, so a sequential
id would let anything that can reach the client actor write into a
stream it never opened. The token is 64 bits of crypto-grade
randomness, which makes the lookup itself the ownership check — pass
the handle around exactly as carefully as you would a write handle.
On the server, a client-streaming method is registered with
kind: 'clientStream' and its handler consumes the request stream
via onData, answering once:
class ReportReadingsHandler extends Actor<GrpcClientStreamCall> { override onReceive(call: GrpcClientStreamCall): void { let count = 0; const sink = { tell: (m: GrpcRequestStreamInbound): void => { match(m) .with({ kind: 'chunk' }, () => { count++; }) .with({ kind: 'end' }, () => call.respond({ count })) .exhaustive(); } } as unknown as ActorRef<GrpcRequestStreamInbound>; call.onData(sink); }}Chunks that arrive before the handler subscribes are held and
replayed on the first onData, so nothing is lost in the turn
between the call landing in the handler’s mailbox and the handler
running.
Bi-directional streaming
Section titled “Bi-directional streaming”Bidi opens the same way but keeps both directions open:
client.tell({ kind: 'bidiStart', method: 'Chat', target: collector });It still uses the older in-band handshake: the actor delivers a
first stream-data frame whose chunk is { __streamId }, and
bidiSend / bidiClose address that bare number.
// Inside the collector, after receiving the streamId hint:const streamId = (message.chunk as { __streamId: number }).__streamId;
client.tell({ kind: 'bidiSend', streamId, chunk: { text: 'hello' } });client.tell({ kind: 'bidiClose', streamId });That handshake is a known weakness — the hint is indistinguishable
from real stream data, and the id is guessable — tracked as
#788. The
stream-started frame and the capability handle above are what it
will adopt; write new code against the client-stream shape where you
have the choice.
Server-stream calls also carry a streamId on their stream-data
/ stream-end frames, so a single collector can multiplex several
concurrent streams. Streams stay open until either side closes
them or the actor stops.
TLS is opt-in via withCredentials. When omitted, both actors
default to insecure ({ kind: 'insecure' }). Certificates are
supplied as Uint8Array values, not file paths:
import { readFileSync } from 'node:fs';
// Server: supply cert + key. Add `rootCerts` to require client// certs (mTLS).const grpcServerOptions = GrpcServerOptions.create() .withProtoPath(protoPath) .withPackageName('sensor.v1') .withServiceName('SensorService') .withBind('0.0.0.0:50051') .withHandlers({ /* … */ }) .withCredentials({ kind: 'tls', cert: readFileSync('./server.crt'), key: readFileSync('./server.key'), });new GrpcServerActor( grpcServerOptions,);
// Client: pass `rootCerts` to verify the server; add cert + key for mTLS.const grpcClientOptions = GrpcClientOptions.create() .withProtoPath(protoPath) .withPackageName('sensor.v1') .withServiceName('SensorService') .withEndpoint('sensor.svc:50051') .withCredentials({ kind: 'tls', rootCerts: readFileSync('./ca.crt'), });new GrpcClientActor( grpcClientOptions,);For mutual TLS (mTLS), add the peer’s cert + key to the
credentials object.
Health checking
Section titled “Health checking”The server can host the standard grpc.health.v1.Health
service next to yours, so grpc_health_probe, the Kubernetes gRPC
probe and gRPC load balancers can ask the node whether it is ready.
The status is not a second notion of “healthy”: it comes from
the same HealthCheckRegistry
that feeds the management server’s /ready endpoint. Pass the
registry to withHealth — that is the opt-in:
import { HealthCheckRegistry } from 'actor-ts';
const health = new HealthCheckRegistry();health.addReadiness(() => ({ name: 'journal', status: journal.isConnected() }));
const grpcServerOptions = GrpcServerOptions.create() .withProtoPath(protoPath) .withPackageName('sensor.v1') .withServiceName('SensorService') .withBind('0.0.0.0:50051') .withHandlers({ /* … */ }) .withHealth(health);Check answers SERVING only while every readiness check
passes, and NOT_SERVING as soon as one fails — the same rule
/ready applies. It is re-evaluated per call, so nothing is
cached. If you already run the management server, hand it the
registry managementRoutes(...) returned and both endpoints stay
in lockstep.
The HealthCheckRequest.service field selects what is being asked
about:
service | Answer |
|---|---|
'' (empty) | The whole server — the usual probe. |
sensor.v1.SensorService | The served service, fully qualified. |
SensorService | Same, bare name accepted as a convenience. |
grpc.health.v1.Health | The health service itself. |
| anything else | NOT_FOUND (status code 5). |
Only Check is implemented; Watch answers UNIMPLEMENTED, which
is the documented signal for a client to fall back to polling
Check.
Configuration
Section titled “Configuration”Per-instance builder options take precedence over HOCON, which
takes precedence over built-in defaults. The HOCON keys live under
actor-ts.io.broker.grpc.server and actor-ts.io.broker.grpc.client:
actor-ts.io.broker.grpc { server { protoPath = "./proto/sensor.proto" packageName = "sensor.v1" serviceName = "SensorService" bind = "0.0.0.0:50051" } client { protoPath = "./proto/sensor.proto" packageName = "sensor.v1" serviceName = "SensorService" endpoint = "sensor.svc:50051" deadlineMs = 30s }}Handlers and TLS credentials are supplied through the builder, not HOCON.
Peer dependency
Section titled “Peer dependency”npm install @grpc/grpc-js @grpc/proto-loader# or: bun add @grpc/grpc-js @grpc/proto-loaderBoth packages are peer dependencies.
When to use gRPC
Section titled “When to use gRPC”Two primary fits:
- Service-to-service inside a cluster where Protobuf-typed contracts matter for evolution.
- External clients that already speak gRPC (mobile apps, other-language services).
For internal actor-to-actor communication inside a cluster, the cluster transport is better — typed via TypeScript directly, no Protobuf required. gRPC is for cross-language or external-contract cases.
Where to next
Section titled “Where to next”- I/O overview — the bigger picture.
- BrokerActor base — the shared lifecycle.
- Refs across nodes — for cluster-internal RPC (TypeScript-typed alternative).
- HTTP overview — for HTTP-based RPC instead.
