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.
The type set is Thrift's rather than a convenient subset: uuid as sixteen raw bytes with no length in front, set with a field type of its own, structs nested inside structs, maps of maps. Two failures and one silence complete it — a handler that raises answers a TApplicationException instead of dropping the connection, and a method the IDL declares oneway runs without a reply.
// a handler that fails answers an application exception, not silence
processor.add("find", proto => {
ignore(proto.read_field_begin())
let key = proto.read_string()
ignore(proto.read_field_begin())
if key == "ada" {
proto.write_field_begin(@thrift.type_string, 0)
proto.write_string("found")
} else {
raise @thrift.Application("no such key")
}
})
// a oneway method runs and answers nothing; the flag belongs to the method,
// because a client may send it as an ordinary CALL and never read a reply
processor.add_oneway("notify", proto => {
ignore(proto.read_field_begin())
ignore(proto.read_string())
})
Apache Thrift's own cross test runs against this stack in both directions: their Go client calls the service served here, and the client here calls their Go server, in the binary and in the compact protocol. All 23 methods of ThriftTest — structs nested two deep, maps of maps, sets, a uuid, a oneway method, a value in field 0, a declared exception in its own field, and the application exception a failing handler produces — pass both ways. src/cmd/thriftcrosstest is this side of it, and Conformance lists what the suite covers and what it does not.
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.