Skip to content

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

weld
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:

weld
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:

ModeSignatureCalled as
Unaryrpc M(Req) -> Resptry svc.M(req)
Server-streamingrpc M(Req) -> Stream<Resp>for x in svc.M(req) { … }
Client-streamingrpc M(Stream<Req>) -> Resptry svc.M([req, …])
Bidirectionalrpc 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:

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

weld
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 to database/upstream/grpc, and try. fail <code> "msg" returns a gRPC status trailer (e.g. 3 INVALID_ARGUMENT, 5 NOT_FOUND); any other error becomes 13 INTERNAL.
  • 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 returns 12 UNIMPLEMENTED. 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 a FileDescriptorProto from the Weld message and service definitions, scoped per listenerlist on 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):

weld
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:

weld
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:

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