gRPC
Call a gRPC service over a native, from-scratch HTTP/2 (h2c) + protobuf stack — no C, no gRPC library. Protobuf messages are records with @field-number annotations; a grpc block declares the service and its rpcs (fallible calls).
type HelloRequest = { name: string @1, times: int @2 }
type HelloReply = { message: string @1, length: int @2 }
grpc Greeter {
addr env("GRPC_ADDR") # host:port
rpc SayHello(HelloRequest) -> HelloReply
}
route GET "/hello/:name" (name: string) -> HelloReply {
respond try Greeter.SayHello(HelloRequest { name: name, times: 1 })
}Codegen emits a protobuf encoder/decoder per message, and per-rpc a call that opens cleartext HTTP/2, sends the request over /Service/Rpc, and decodes the reply. Verified end-to-end against a real @grpc/grpc-js server and against a Weld-hosted service.
Package-qualified services
The block name (Greeter) is the identifier you call in Weld — it must be a plain identifier, so it can't hold the package. When the wire service name is package-qualified (as most are, e.g. greeter.v1.Greeter), give it explicitly with service:
grpc Greeter {
addr env("GRPC_ADDR")
service greeter.v1.Greeter # the /greeter.v1.Greeter/… wire path
rpc SayHello(HelloRequest) -> HelloReply
}weld import proto <file> fills this in automatically from the proto's package. Without a service line, the path uses the block name (an unpackaged service). Outbound calls are traced: each becomes a client span (grpc greeter.v1.Greeter/SayHello) carrying the current traceparent, so the callee joins the same trace.
Streaming modes
All four modes are supported:
| Mode | Signature | Called as |
|---|---|---|
| Unary | rpc M(Req) -> Resp | try svc.M(req) |
| Server-streaming | rpc M(Req) -> Stream<Resp> | for x in svc.M(req) { … } |
| Client-streaming | rpc M(Stream<Req>) -> Resp | try svc.M([req, …]) |
| Bidirectional | rpc M(Stream<Req>) -> Stream<Resp> | for x in svc.M([req, …]) { … } |
A Stream<Req> request side takes a [Req] list — each element is sent as its own gRPC-framed DATA frame, half-closing on the last. For bidirectional calls a dedicated sender thread streams requests while the reply reader runs, so the call genuinely sends and receives at the same time.
Pair a response stream with emit to turn it into an HTTP chunked response with no buffering — one gRPC message becomes one HTTP chunk:
rpc SayHelloStream(HelloRequest) -> Stream<HelloReply> # server-streaming
route GET "/hellostream/:name" (name: string) {
type "text/plain"
for reply in Greeter.SayHelloStream(HelloRequest { name: name, times: 3 }) {
emit "{reply.message}\n" # one gRPC message -> one HTTP chunk
}
}Response streams run in constant memory: each message's scratch allocations are rewound after its loop iteration, so a multi-gigabyte stream is emitted chunk-by-chunk without ever being held whole.
Hosting a service
The grpc block above calls a service; a service block implements one — Weld is a native h2c gRPC server too. Each rpc is written like a route: the request message is a parameter, and respond returns the response message. A service is served over HTTP/2 cleartext on its own listener, so it lives alongside any HTTP routes/hubs (which stay on other ports).
type HelloRequest = { name: string @1 }
type HelloReply = { message: string @1 }
listen grpc { port 50051 } # the h2c listener (or GRPC_PORT at runtime)
service greeter.Greeter on grpc {
rpc SayHello(req: HelloRequest) -> HelloReply {
if len(req.name) == 0 { fail 3 "name is required" } # 3 = INVALID_ARGUMENT
respond HelloReply { message: "Hello, {req.name}!" }
}
}- The service name (
greeter.Greeter) and method map to the wire path/greeter.Greeter/SayHello. - A handler is a full route-style body:
state,let/var,if/match, calls todatabase/upstream/grpc, andtry.fail <code> "msg"returns a gRPC status trailer (e.g.3INVALID_ARGUMENT,5NOT_FOUND); any other error becomes13INTERNAL. - The listener a service is bound to speaks h2c and can't also serve HTTP routes/hubs — give gRPC its own
listen. - Services are isolated to their listener. With several
service … on <listener>on different ports, each service answers only on its own port; calling it on another gRPC port returns12UNIMPLEMENTED. Two services may share one listener (dispatched by path). - Server reflection is built in, so tools work with no local
.proto:grpcurl -plaintext localhost:50051 list/describe greeter.Greeter, and invoking by reflection all work. The server synthesizes aFileDescriptorProtofrom the Weld message and service definitions, scoped per listener —liston a port shows only that port's services.
Server-streaming
A -> Stream<Resp> response makes the rpc server-streaming: the handler pushes any number of messages with emit, and the stream closes with an OK trailer when it returns (or a fail <code> trailer mid-stream):
type Ticket = { n: int @1 }
type Tick = { seq: int @1, label: string @2 }
service demo.Feed on grpc {
rpc Countdown(t: Ticket) -> Stream<Tick> {
var xs = [1, 2, 3]
for i, x in xs {
if x > t.n { fail 9 "over the limit" } # 9 = FAILED_PRECONDITION, closes the stream
emit Tick { seq: x, label: "tick-{i}" }
}
}
}emit <Msg> sends and flushes one response message; the handler uses emit (not respond). Reflection reports the method as server_streaming, so grpcurl … Countdown streams the messages as they're produced.
Client-streaming
A Stream<Req> request makes the rpc client-streaming: the client sends any number of messages, and the handler receives them all as a [Req] list, replying once with respond:
type Num = { v: int @1 }
type Sum = { total: int @1, count: int @2 }
service demo.Adder on grpc {
rpc Add(nums: Stream<Num>) -> Sum {
var total = 0
var count = 0
for x in nums { # iterate the collected request messages
total = total + x.v
count = count + 1
}
respond Sum { total: total, count: count }
}
}The request messages are buffered and handed to the handler when the client half-closes the stream (so a client sending zero messages yields an empty list).
Bidirectional streaming
Stream<Req> -> Stream<Resp> is bidirectional: the handler consumes the request stream with for m in <req> and emits responses, interleaved in real time — each emit is flushed as it runs, before the client half-closes, so an interactive client gets replies as it sends:
type Msg = { text: string @1 }
type Reply = { text: string @1 }
service demo.Chat on grpc {
rpc Echo(msgs: Stream<Msg>) -> Stream<Reply> {
for m in msgs { # each message as it arrives off the wire
emit Reply { text: "echo: {m.text}" } # ...answered immediately
}
}
}The for m in <req> loop pulls one request message at a time (answering interleaved gRPC control frames), and ends when the client half-closes. A mid-stream fail <code> closes the stream with that status. The loop is sequential (read a message, emit responses, repeat), which covers the common request/response bidi patterns.
- Scope today: unary and all three streaming modes (server, client, bidirectional) over h2c. The header decoder is full HPACK (static + dynamic table + Huffman, verified against the RFC 7541 vectors), so standard clients (grpcurl, Go/Java gRPC) interoperate. TLS hosting is not implemented — terminate TLS at a proxy/load-balancer in front of the h2c port (the usual gRPC deployment).
Transport note
Transport is cleartext h2c today. gRPC-over-TLS (h2 on port 443) requires ALPN to negotiate the h2 protocol during the TLS handshake, and Zig's std.crypto.tls.Client does not yet expose ALPN — so native gRPC-over-TLS to a standard server is blocked at the std layer until ALPN lands. HTTPS upstreams are unaffected (HTTP/1.1 over TLS needs no ALPN) and work today.