A walk through moonrpc: define a message, handle a call, run the two pure engines in memory, then put a real socket in front of them. Every snippet uses the API as it ships.
Add the package with the MoonBit toolchain. The root package re-exports the pure engines and every typed helper, so one import is enough.
$ moon add moonbitstack/moonrpc
import "moonbitstack/moonrpc" // @moonrpc
// sockets, when you need them:
import "moonbitstack/moonrpc/net" // @net (native only)
@net is flagged supported_targets="native"; everything under @moonrpc compiles to JS, native and Wasm.
A gRPC call is two state machines trading HTTP/2 frames. H2Client and H2Server are pure: you hand each one a frame, it returns the frames to send back. No socket, no I/O — just data in, data out. That is why the same code runs in a server and in the browser showcase.
let server = @moonrpc.H2Server::new()
let client = @moonrpc.H2Client::new()
// client.unary opens a stream and returns the first frames to send
let (id, frames) = client.unary("/greet.Greeter/SayHello", request_bytes)
// shuttle frames between the engines until the call finishes
while !client.is_done(id) {
for f in frames { for r in server.feed(f) { /* frames to client */ } }
// ... and back: client.feed(r) -> frames to server
}
See the whole loop in src/examples/07-unary-call. The homepage shows the nine frames a single unary call produces.
A message is just bytes on the wire, packed by the protobuf codec. You can do it by hand, or let #rpc.message derive the codec from the struct.
fn encode_request(name : String) -> Bytes {
let w = @moonrpc.PbWriter::new()
w.string_(1, name) // field 1, wire type LengthDelimited
w.to_bytes()
}
fn decode_name(msg : Bytes) -> String {
let r = @moonrpc.PbReader::new(msg)
let _ = r.read_tag() // consume the tag for field 1
r.read_string()
}
#rpc.message (derives @moonrpc.Message)#rpc.message
pub struct HelloRequest {
#rpc.field(number="1")
name : String
}
#rpc.message
pub struct HelloReply {
#rpc.field(number="1")
name : String
}
#rpc.enum
pub enum Mood { Calm = 0; Glad = 1 } // MOOD_CALM = 0, MOOD_GLAD = 1
#rpc.message
pub struct Profile {
mood : Mood // an enum field
scores : Map[String, Int64] // map<string, int64>
nickname : String? // proto3 optional
}
#rpc.field(number="…") pins the protobuf tag. Omitted fields get consecutive numbers; #rpc.skip leaves a field out of the wire form. An enum's values take MoonBit's own tags, or count up from 0.
Register a path and a function from request bytes to reply bytes. For typed messages, @moonrpc.unary decodes the input and encodes the output for you.
let server = @moonrpc.H2Server::new()
// hand-rolled: request bytes -> reply bytes
server.register("/greet.Greeter/SayHello", req => {
let name = decode_name(req)
let w = @moonrpc.PbWriter::new()
w.string_(1, "hello, " + name)
w.to_bytes()
})
// typed: works once HelloRequest / HelloReply are #rpc.message
server.register_handler(
"/greet.Greeter/SayHello",
@moonrpc.unary(fn (_ctx : @moonrpc.RpcContext, req : HelloRequest) -> HelloReply {
{ name : "hello, " + req.name }
}),
)
Or describe the whole service once and let #rpc wire it up:
#rpc.service(name="greet.Greeter")
pub struct Greeter {}
#rpc.method
pub fn Greeter::say_hello(
self : Greeter, _ctx : @moonrpc.RpcContext, req : HelloRequest,
) -> HelloReply {
{ name : "hello, " + req.name }
}
// later:
let greeter = Greeter::{}
greeter.register(server) // generated by #rpc.service
The method's type decides the call kind: a plain reply is unary, an Array[…] reply is server-streaming, an Array[…] request is client-streaming, and both is bidi.
Because the engines are pure, a full call needs no socket — only the frame shuttle. This is the exact shape the showcase runs in your browser.
let server = @moonrpc.H2Server::new()
server.register("/greet.Greeter/SayHello", req => /* … */)
let client = @moonrpc.H2Client::new()
let (id, to_server) = client.unary("/greet.Greeter/SayHello", encode_request("heke"))
let mut to_client : Array[@http2.Frame] = []
let mut steps = 0
while !client.is_done(id) && steps < 100000 {
steps = steps + 1
for f in to_server { for r in server.feed(f) { to_client.push(r) } }
to_server = []
for r in to_client { for f in client.feed(r) { to_server.push(f) } }
to_client = []
}
let reply = client.reply(id)
println("grpc-status \{reply.grpc_status}")
println("reply: \{decode_name(reply.messages[0])}")
To actually accept TCP connections you add the native socket layer. @net.GrpcServer wraps an engine and listens on a port — native only, because MoonBit's async runtime has no JS/Wasm sockets.
let greeter = Greeter::{}
let server = @net.GrpcServer::new()
greeter.register(server) // the #rpc-generated registrar
server.serve() // listens on :12000 (native target)
// elsewhere, the client #rpc.service generates alongside:
let client = GreeterClient::new(@net.Channel::connect("127.0.0.1", @net.port))
let reply = client.say_hello({ name: "heke" }) // a HelloReply, or a StatusError
Build with moon build --target native. The engines you tested in memory are the same ones serving here.
All four are the same frame machinery; only the number of messages each side sends differs. Each has a typed wrapper under @moonrpc.
| Kind | Request | Reply | Typed wrapper |
|---|---|---|---|
| Unary | one | one | @moonrpc.unary |
| Server streaming | one | many | @moonrpc.server_streaming |
| Client streaming | many | one | @moonrpc.client_streaming |
| Bidi streaming | many | many | register_bidi + BidiHandler |
// server streaming: one request, a sequence back
server.register_handler(
"/demo.Counter/Up",
@moonrpc.server_streaming(fn (_ctx, _req : UpRequest) -> Array[UpReply] {
[{ n : 1 }, { n : 2 }, { n : 3 }]
}),
)
Run all four live in the Showcase — each shows the frames it produces.
Raise a StatusError and the engine answers the call with that grpc-status and message in the trailers. Any other error becomes UNKNOWN.
server.register_handler(
"/greet.Greeter/SayHello",
@moonrpc.unary(fn (_ctx, req : HelloRequest) -> HelloReply raise {
if req.name == "" {
raise @wire.StatusError(@wire.Status::InvalidArgument, "name required")
}
{ name : "hello, " + req.name }
}),
)
// on the client:
let reply = client.reply(id)
// reply.grpc_status -> Int32 (0 = OK)
// reply.grpc_message -> String
The API reference lists every public item by package. The Showcase runs the real engine in your browser. The repository's src/examples/* cover HTTP/2 frames, HPACK, flow control, health, reflection, deadlines, retries and more.