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.
The switch changes the service-definition examples in §4 and §12; protocol-level snippets are the same in all three styles.
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.
#rpc.messageThe annotation 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
}
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.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 same service in the other two forms — switch the writing style at the top of the page:
///|
/// The guide's annotation variant: a message, a service and its method, all
/// declared with `#rpc`. `cmd/rpc` writes `service.g.mbt` from this file.
#rpc.message
pub struct HelloRequest {
name : String
}
///|
#rpc.message
pub struct HelloReply {
message : String
}
///|
#rpc.service(name="greet.Greeter")
pub struct Greeter {}
///|
#rpc.method
fn Greeter::say_hello(self : Greeter, req : HelloRequest) -> HelloReply {
ignore(self)
{ message: "Hello, " + req.name, }
}Hello, Ada///|
/// Run the same greeter registered as functions on the server.
///
/// moon run src/examples/guide/functional
fn encode_request(name : String) -> Bytes {
let w = @moonrpc.PbWriter::new()
w.string_(1, name)
w.to_bytes()
}
///|
fn decode_name(msg : Bytes) -> String {
try {
let r = @moonrpc.PbReader::new(msg)
let _ = r.read_tag()
r.read_string()
} catch {
_ => ""
}
}
///|
fn encode_reply(message : String) -> Bytes {
let w = @moonrpc.PbWriter::new()
w.string_(1, message)
w.to_bytes()
}
///|
fn decode_reply(msg : Bytes) -> String {
try {
let r = @moonrpc.PbReader::new(msg)
let _ = r.read_tag()
r.read_string()
} catch {
_ => ""
}
}
///|
fn main {
let reply = @harness.round_trip(
server => {
server.register_unary("/greet.Greeter/SayHello", (_ctx, req) => {
encode_reply("Hello, " + decode_name(req))
})
},
"/greet.Greeter/SayHello",
encode_request("Ada"),
) catch {
_ => abort("the call failed")
}
println(decode_reply(reply))
}Hello, Ada///|
/// Run the same greeter written as a struct with methods.
///
/// moon run src/examples/guide/class
struct Greeter {
prefix : String
}
///|
fn encode_request(name : String) -> Bytes {
let w = @moonrpc.PbWriter::new()
w.string_(1, name)
w.to_bytes()
}
///|
fn decode_name(msg : Bytes) -> String {
try {
let r = @moonrpc.PbReader::new(msg)
let _ = r.read_tag()
r.read_string()
} catch {
_ => ""
}
}
///|
fn encode_reply(message : String) -> Bytes {
let w = @moonrpc.PbWriter::new()
w.string_(1, message)
w.to_bytes()
}
///|
fn decode_reply(msg : Bytes) -> String {
try {
let r = @moonrpc.PbReader::new(msg)
let _ = r.read_tag()
r.read_string()
} catch {
_ => ""
}
}
///|
fn Greeter::say_hello(self : Greeter, req : Bytes) -> Bytes {
encode_reply(self.prefix + ", " + decode_name(req))
}
///|
fn Greeter::register(self : Greeter, server : @moonrpc.H2Server) -> Unit {
server.register_unary("/greet.Greeter/SayHello", (_ctx, req) => {
self.say_hello(req)
})
}
///|
fn main {
let reply = @harness.round_trip(
server => Greeter::{ prefix: "Hello", }.register(server),
"/greet.Greeter/SayHello",
encode_request("Ada"),
) catch {
_ => abort("the call failed")
}
println(decode_reply(reply))
}Hello, Ada 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
HTTP/2 over TLS is chosen by ALPN: both ends name h2 in the handshake and check what it settled on before a frame is exchanged. @net.server_tls and @net.client_tls build the two configurations with h2 alone, so the agreement cannot be forgotten.
// the server: a certificate chain, leaf first, and its key
let server = @net.GrpcServer::new()
greeter.register(server)
server.serve(port=12000, tls=@net.server_tls(@x509.pems(cert_text), @x509.PrivateKey::pem(key_text)))
// the client: the CA that signed the server, and nothing else to configure
let chan = @net.Channel::connect("api.example.com", 12000, tls=@net.client_tls(ca_roots))
let reply = chan.unary("/greet.Greeter/SayHello", request)
MuxChannel::connect, ManagedChannel::connect and dns_connect take the same tls. A peer that offers no h2 is closed rather than spoken to in a version it did not agree to, and a handshake failure on serve is a refused client, not a broken server.
One interceptor shape covers all four call kinds, on both ends: a call is the request messages it carries and the replies they produce.
// server: (info, ctx, requests, next) -> replies
server_engine.add_interceptor((info, ctx, requests, next) => {
let started = @async.now()
let replies = next(requests) // or answer here and never call it
println("\{info.path} took \{@async.now() - started} ms")
replies
})
// client: (invocation, next) -> reply
chan.add_interceptor((invocation, next) => {
let metadata = invocation.metadata.copy()
metadata.push({ name: b"authorization", value: b"Bearer ..." })
next({ ..invocation, metadata })
})
An interceptor sees CallInfo — the path and which sides stream — so passing unary calls through untouched and handling only those is one if. The client chain wraps a call once, outside the retry loop: a retry is one invocation to an interceptor.
gRPC names three encodings — identity, gzip and deflate — and negotiates them with grpc-encoding and grpc-accept-encoding. Set one and messages go compressed; a peer that did not ask for it still gets identity.
// the server answers in gzip to a caller that accepts it
let server = @net.GrpcServer::new()
server.set_encoding(@moonrpc.Encoding::Gzip)
greeter.register(server)
// the client compresses its own requests, and decodes whatever comes back
let chan = @net.Channel::connect("127.0.0.1", 12000, encoding=@moonrpc.Encoding::Gzip)
let reply = chan.unary("/greet.Greeter/SayHello", request)
A message whose compression flag is set under identity — compressed and not compressed at once — is refused rather than read as plaintext, and a body that will not inflate fails the call with INTERNAL.
moonrpc accepts the same service three ways. The annotations come first: they generate the protobuf codec, the registration and the descriptor reflection serves. The functional form registers handlers directly on the server; the class form keeps state on a struct and registers its methods. All three answer the same request with the same bytes.
The three variants are the same service as §4; switch them with the bar at the top of the page, where the choice is remembered. Protocol-level sections — frames, HPACK, TLS, compression — are identical in all three styles, so they are shown once.
The same module also speaks Apache Thrift: TBinaryProtocol and TCompactProtocol over TFramedTransport, written against one Protocol trait, so the processor, the client request and skip exist once. A Thrift peer is a different conversation from a gRPC one — nothing here goes through HTTP/2.
// serve it on 9090; a Compact here would be the other protocol, same service
let processor = @thrift.Processor::new()
processor.add("ping", proto => {
ignore(proto.read_field_begin()) // field 1: the argument
let text = proto.read_string()
ignore(proto.read_field_begin()) // STOP
proto.write_field_begin(@thrift.type_string, 0) // field 0: the result
proto.write_string("pong:" + text)
})
@net.serve_thrift(
host="127.0.0.1", port=9090, processor, () => @thrift.Binary::new(b""),
)
// the client, over the same protocol object
let proto : &@thrift.Protocol = @thrift.Compact::new(b"")
let client = @net.ThriftClient::connect("127.0.0.1", 9090, proto)
let pong = @thrift.call(client, proto, "ping", 1, proto => {
proto.write_field_begin(@thrift.type_string, 1)
proto.write_string("moonrpc")
}, proto => proto.read_string())
A call is one framed round trip. The flag picks the bytes, not the service: the two protocols differ in how a field is numbered and how a double is ordered, and the trait is what keeps that difference in one place.
Writing those bytes by hand gets old, so cmd/thriftc compiles the IDL instead: types with their codecs, each service's registration and client, and the constants.
// greet.thrift — the shapes the compiler reads
typedef map<string, i32> Scores
exception Missing {
1: string message
}
union Contact {
1: string email
2: i64 phone
}
struct Person {
1: required string name
2: optional i32 age
3: Scores scores
}
const i32 MAX_AGE = 130
service Greeter {
string find(1: string key) throws (1: Missing missing)
}
// what thriftc writes
pub(all) struct Person { name : String, age : Option[Int32], scores : Map[String, Int32] }
pub(all) enum Contact { Email(String); Phone(Int64) }
pub(all) enum GreeterFindError { Missing(Missing) }
pub let maxAge : Int32 = 130
// a method that declares throws answers a Result, on both ends
find : (String) -> Result[String, GreeterFindError]
let found = greeter_find(transport, proto, "ada") // Result[String, GreeterFindError]
include folds another file's types in under a prefix (shared.Person becomes SharedPerson) and service Child extends Base carries the base's methods into the child's registration. A typedef is substituted where it is used, so no alias reaches the generated code; a union becomes an enum that carries exactly one field. CI compiles the example with --check, so the generated file and the IDL cannot drift.
An Apache Thrift Go client calls cmd/thriftd and the moonrpc client calls an Apache Thrift Go server, in the binary and in the compact protocol. One of those methods answers a value in field 0 or an exception in field 1 — the shape a throws clause gives a reply — and both peers agree on it.
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, the Thrift protocols and the IDL compiler.