gRPC surface
Serve opt-in procedures to generated gRPC clients — proto emitted from your effect/Schema with checked-in field-number stability, unary for mutations/actions, server-streaming for live queries, guards + interceptors + typed errors identical to the socket.
The gRPC surface serves a NAMED list of your procedures to external gRPC
clients — the polyglot-microservice door. The .proto is generated from the
same effect/Schema your procedures already declare, so there is no second
contract to maintain; the wire semantics are the framework's own: guards,
plugin interceptors and typed errors behave identically to the rpc
socket, because a gRPC call runs the same bound runner every other surface
uses (the e2e proves interceptor order side by side).
// app.config.ts
export default {
type: 'api' as const,
name: 'api',
grpc: {
port: 50051,
procedures: ['orders.get', 'orders.list', 'orders.create'],
// tls: { certPath, keyPath, caPath? } — plaintext without it (dev / mesh).
// drainMs: 5000, // shutdown drain budget — see below
// maxMessageBytes, maxMetadataBytes — grpc-js frame limits
},
}NOTHING is exposed by default — every tag is named. Booting writes
.framework/grpc.proto (hand it to any proto codegen) and mounts
grpc.health.v1 health checking plus server reflection (grpcurl … list
works out of the box). The gRPC packages ship as script-free optional
dependencies of @voltro/cli; a configured grpc: block with them missing
refuses the boot by name.
Shutdown drains, then forces — drainMs
On SIGTERM the surface flips its health status to NOT_SERVING (so a load
balancer stops sending it work) and gives open calls drainMs to finish
before force-closing them. Default 5000; 0 forces immediately; the env
override is VOLTRO_GRPC_DRAIN_MS.
Pick it from two numbers only you have. Keep it below your orchestrator's
termination grace (terminationGracePeriodSeconds, docker stop -t) — past
that point SIGKILL arrives and the drain never completes, so a larger budget
buys nothing. Keep it above your longest legitimately in-flight unary
call, or every rolling deploy force-closes work that would have finished. When
the budget is exceeded the surface says so in a warning naming the budget,
rather than leaking the port into the next boot.
Field numbers are managed — grpc.manifest.json
Field numbers are the proto wire identity, so they may never depend on property order. They come from a checked-in manifest in your app root:
- a new field gets the next never-used number — an inserted field never renumbers its neighbours;
- a deleted field's number becomes
reserved(emitted into the proto, soprotocrefuses a colliding hand-edit too); - reusing a reserved number is a codegen error, never a warning — an old client would silently read the wrong field.
Commit the manifest with the schema change that moved it: the diff review IS the wire-contract review.
The mapping table
| Schema | proto3 |
|---|---|
Schema.String / Number / Boolean |
string / double / bool |
| integer schemas | int64 |
Schema.Array(T) |
repeated T |
nested Schema.Struct |
nested message |
Schema.Record({ key: String, value: T }) |
map<string, T> |
Schema.optional(T) and Schema.NullOr(T) |
optional T — absent and null are ONE wire state (proto3 presence) |
| string-literal unions | string (validated server-side on decode) |
| unions of shapes, tuples, recursion, free-form objects | a LOUD per-procedure codegen error naming the schema path |
Requests are decoded against the descriptor's input schema before the executor runs — proto3 suppresses default values on the wire, and without that decode an empty string would arrive as an absent field and fail somewhere much later.
Status codes — complete against the wire error union
| outcome | gRPC status | trailers |
|---|---|---|
| no credential on a guarded call | UNAUTHENTICATED |
|
| presented-and-rejected credential | UNAUTHENTICATED |
|
authenticated, missing scope (ScopeError) |
PERMISSION_DENIED |
voltro-error: scope |
| input fails the schema | INVALID_ARGUMENT |
voltro-error: input |
BusinessRuleViolation |
FAILED_PRECONDITION |
voltro-error: rule |
requiresApproval pending — a FLOW OUTCOME, not a failure |
FAILED_PRECONDITION |
voltro-pending: approval + voltro-approval-id |
| your declared typed error | FAILED_PRECONDITION |
voltro-error: <tag> |
| deadline exceeded | DEADLINE_EXCEEDED |
|
| anything else | INTERNAL |
Deadlines interrupt the work. A client deadline (grpc-timeout) aborts
the executor's fiber through the request signal — the server stops doing the
work, it does not merely suppress the response (the e2e pins this with a
sleeping action whose post-sleep write never lands).
Streaming queries
A query becomes a server-streaming rpc: each frame is the CURRENT full
snapshot, re-pushed live when the query's source: changes — subscribe,
mutate from anywhere, and the open stream receives the new frame with no
re-request.
Authorization is re-derived per FRAME, not frozen at open. Before every
delivery the framework re-runs the query's guards: and re-resolves the
subject's row-level visibility
from the unfiltered base descriptor. A revoked scope ends the stream with the
mapped status; a membership that ends mid-stream stops carrying those rows in
the next frame, with the stream itself untouched. This is the same code the
WebSocket and SSE transports run — an open gRPC stream is not a cheaper read
path than a fresh call.
Slow consumers are handled through grpc-js write backpressure — frames coalesce to the latest snapshot rather than buffering unboundedly.
Declared limits (v1)
- No client- or bidi-streaming, and
*.stream.tsprocedures are NOT exposable — the fourth kind is a one-shot element stream with its own semantics; put it behind a query or keep it on the socket. - No gRPC-Web — a browser talks the framework's own subscription protocol (that is the better browser transport in every dimension we care about); gRPC is for backends.
- No Connect protocol — connectrpc is NOT gRPC-Web; a connect consumer's alternative today is the REST/OpenAPI projection.
- App realtime stays on the framework's subscription protocol, and gateways exist for the other case: a FOREIGN protocol that needs a socket the framework does not speak — the same boundary data/subscriptions draws for raw WebSocket gateways, one sentence, two doors.