コンテンツにスキップ
日本語

gRPC

このコンテンツはまだ日本語訳がありません。

The framework provides two actor classes for gRPC:

ClassRole
GrpcServerActorHosts a gRPC server; exposes service methods.
GrpcClientActorConnects 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 builderGrpcServerOptions.create()… / GrpcClientOptions.create()… — and spawned like any other actor.

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'.

The framework de-serializes the Protobuf payload and hands the handler a typed call object:

Handler kindCall typeReply API
unaryGrpcUnaryCallrespond(res) / respondError(message, code?)
serverStreamGrpcServerStreamCallsend(chunk) / complete() / fail(message, code?)
clientStreamGrpcClientStreamCallonData(target) / respond(res) / respondError(message, code?)
bidiGrpcBidiCallonData(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' }.

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).

Replies and stream frames arrive at the target actor as a GrpcInbound discriminated union:

kindFieldsMeaning
replyresponseUnary or client-stream completion.
stream-startedhandleA client stream is open — see below.
stream-datastreamId, chunkOne stream chunk.
stream-endstreamIdStream closed cleanly.
stream-errorstreamId, errorStream failed.
rpc-errorerrorUnary / call setup failed.

gRPC has four call types, and the framework covers all four:

TypeClient sendsServer returnsClient-side kinds
UnaryOne requestOne responseunary
Server-streamingOne requestStream of responsesserverStream
Client-streamingStream of requestsOne responseclientStreamStart / clientStreamSend / clientStreamClose
Bi-directional streamingStream of requestsStream of responsesbidiStart / 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 });

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.

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.

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:

serviceAnswer
'' (empty)The whole server — the usual probe.
sensor.v1.SensorServiceThe served service, fully qualified.
SensorServiceSame, bare name accepted as a convenience.
grpc.health.v1.HealthThe health service itself.
anything elseNOT_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.

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.

Terminal window
npm install @grpc/grpc-js @grpc/proto-loader
# or: bun add @grpc/grpc-js @grpc/proto-loader

Both packages are peer dependencies.

Two primary fits:

  1. Service-to-service inside a cluster where Protobuf-typed contracts matter for evolution.
  2. 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.