guide

From a message to a stream

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.

Try it live Full API

1 · Install

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.

2 · Two pure engines

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.

3 · Define a message

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.

By hand

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()
}

With #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.

4 · Handle a unary call

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.

5 · Run it in memory

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])}")

6 · Serve it for real

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.

7 · The four call kinds

All four are the same frame machinery; only the number of messages each side sends differs. Each has a typed wrapper under @moonrpc.

KindRequestReplyTyped wrapper
Unaryoneone@moonrpc.unary
Server streamingonemany@moonrpc.server_streaming
Client streamingmanyone@moonrpc.client_streaming
Bidi streamingmanymanyregister_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.

8 · Status and errors

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

Where to next

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.

Back to home