A service framework for MoonBit — configuration, middleware, zRPC, resilience and discovery in one place.
moonzero assembles a service from a config document: it builds the application,
wraps it in the middleware the config asks for, and hands back an ASGI application
that any host can run. It also speaks zRPC — the h2c gRPC profile with its own
client and server interceptor chains — and it carries the resilience primitives,
the service discovery glue and the small utilities a service ends up needing.
flowchart LR
conf["RestConf"] --> engine["RestEngine"]
app["application"] --> engine
engine -->|"build()"| server["Server"]
server -->|"to_asgi()"| asgi(["AsgiApp"])
asgi --> host["any ASGI host"]
moon add moonbitstack/moonzerolet app = @moonapi.App::new()
let api = @moonzero.Group::new(app, "/api/v1") // a prefix for a set of routes
api.get("/ping", _ctx => @moonapi.text(200, "pong"))
let conf = @moonzero.ServiceConf::new(
name="greet", host="127.0.0.1", port=12000, timeout_ms=3000, log_level=Info,
)
let server = @moonzero.Server::new(conf, app)
.use_(@moonzero.cors(@moonzero.CorsConf::new())) // Access-Control-* headers
.use_(@moonzero.request_id()) // x-request-id per request
.use_(@moonzero.recovery) // 500 while nothing is sent
.use_(@moonzero.logging)
println(server.describe()) // "greet listening on 127.0.0.1:12000"server.to_asgi() is an AsgiApp: hand it to a server, or drive it in a test with
no socket in sight.
| Package | What it does |
|---|---|
rest |
the config-driven engine, the server, route groups and route options |
rest/handler |
the middlewares: CORS, recovery, request ids, deadlines, body caps, gzip, JWT, content signatures, breaker, shedding, rate limits, tracing, metrics, access logs |
rest/files |
reading a directory into the static file set |
zrpc |
the zRPC server, the client channel, the interceptor chains, per-method metrics |
zrpc/socket |
serving a zRPC server on a port, and calling one over TCP (native) |
balance |
round-robin, weighted and power-of-two-choices balancers, the load-balanced channel |
conf |
the config loader and the service, REST, RPC and signature blocks |
breaker |
the client-side adaptive breaker |
shedder |
the adaptive load shedder |
limit |
token bucket, fixed window and connection limit, in memory or on Redis |
stores/cache |
cache-aside over an open store contract, one load per key |
stores/redis |
that contract over Redis |
stores/sql |
statements, rows and transactions over a database driver |
syncx |
one call in flight per key, a body that runs once, an object pool |
collection |
an LRU cache with lifetimes, a fixed-size ring |
mr |
map-reduce, sequential and parallel |
fx |
retry, backoff, deadlines, grouping |
threading |
a fan-out group, a queue drained by one consumer, a newest-only queue |
service |
several services with one lifetime |
telemetry |
the tracer a service runs on when nobody configured one |
clock |
the millisecond clock every timing decision is a function of |
logx |
levelled, structured logging |
proc |
graceful shutdown |
discov |
file-backed discovery for native hosts |
The root re-exports the surface: @moonzero.Breaker, @moonzero.Server,
@moonzero.jwt_sign and the rest all work from one import.
The REST engine listens on 12000 and the zRPC server on 12001. Both are
ordinary parameters over public presets (@moonzero.port, @moonzero.rpc_port),
so a service that wants 8080 writes ServiceConf::new(port=8080). A process that
runs both binds them separately — two listeners cannot hold one port.
CertFile and KeyFile live in the same document and are validated as a pair: a
document that names one without the other is refused at load, so a service cannot
quietly listen in the clear while its operator believes otherwise. Terminating TLS
is the host's job, since the object this library builds is an application.
RestConf loads the etc/*.yaml a scaffolder writes: PascalCase keys, nested
blocks, MOONZERO_* environment overrides, and default=/options=/range=
constraints. A missing required field, or a value outside its range, is an error
rather than a silent default.
let conf = @moonzero.RestConf::from_yaml(
#|Name: greet-api
#|Host: 127.0.0.1
#|Port: 12000
#|Timeout: 5000
#|Middlewares:
#| Gunzip: false
#|Log:
#| Level: error
)
let engine = @moonzero.RestEngine::new(conf)
println(engine.names().join(", "))The engine installs exactly the layers the config's Middlewares flags ask for, in
a fixed order, and leaves out a layer whose own setting disables it — no shedder
without a CPU threshold, no deadline without a budget.
RestConf field |
Meaning |
|---|---|
Service |
name, bind address, request budget, log level |
CertFile, KeyFile |
TLS material; both or neither, refused otherwise |
Verbose |
access logs carry both header sets |
MaxConns, MaxBytes |
connection permits, declared-body cap |
CpuThreshold |
the CPU reading at which shedding starts |
Signature |
content-security keys, tolerance, strict or lenient |
Middlewares |
one flag per layer |
TraceIgnorePaths |
paths left untraced |
A route carries its own settings, applied where it is registered:
let api = @moonzero.Group::new(app, "/api/v1")
let admin = api.sub("/admin").options_(
@moonzero.RouteOptions::new()
.jwt(secret="current", prev_secret=Some("retiring"))
.timeout(500)
.max_bytes(4096)
.priority(engine.priority_shedder()),
)
admin.get("/stats", ctx => @moonapi.text(200, "ok"))| Option | Effect |
|---|---|
jwt |
requires a valid bearer token; prev_secret keeps a rotation working |
signature |
verifies the content-security MAC before the handler runs |
timeout |
abandons the handler at the deadline and answers 503 |
max_bytes |
refuses a declared body past the cap with 413 |
priority |
draws the call from the priority shedder |
prefix |
joins another prefix onto the group's |
sse |
stamps the event-stream media type and no-cache headers |
rest/handler exports each layer as an AsgiApp transformer, so they compose in
any order and none needs the engine to exist:
let clock = @moonzero.Clock::new(() => 0L) // inject time; no hidden reads
let server = @moonzero.Server::new(conf, app)
.use_(@moonzero.maxbytes(1 << 20)) // 413 past 1 MiB
.use_(@moonzero.rate_limit(@moonzero.TokenBucket::new(100.0), clock))
.use_(@moonzero.breaker(@moonzero.Breaker::new(clock)))
.use_(@moonzero.timeout(3000L, clock))
.use_(@moonzero.tracing()) // W3C traceparent in/out
.use_(@moonzero.metrics(metrics, clock)) // path/method/code familiescorsmatches an origin against the allowed set and its subdomains, answers a preflight with204, and never pairs*with credentials.recoveryanswers500only while nothing of the response has gone out; once the status line is on the wire the error is re-raised for the server to close on.gunzipinflates aContent-Encoding: gzipbody, with an optional cap on the inflated size; a body that will not inflate is answered400.content_securityverifiesX-Content-Security: RSA-sealed envelope, HS256-class MAC over the request, a timestamp inside the tolerance, and an AES-ECB payload when the envelope says the body is sealed.metricsrecords two families labelledpath,methodandcode; the labels stay three labels, so a dashboard can select on any one of them.layeradapts anymid.Layerfrom the ASGI toolkit, so a layer written for the host drops in unchanged.
Refusal is what a turned-away request receives — status, body and headers — and
every refusing middleware takes one, so a gateway can answer 429 where a service
alone answers 503.
Both halves of the wire run through one chain: an invoker turns a call into a result, an interceptor wraps an invoker, and the transport is the innermost one.
let rpc = @moonzero.RpcServer::of(@moonzero.RpcServerConf::new(name="greeter"))
rpc.group("hello.Greeter").register("SayHello", req => req)
let ch = @moonzero.RpcChannel::connect(rpc)
.with_interceptors(@moonzero.client_interceptors(conf.middlewares, deps))
let reply = ch.call_through("/hello.Greeter/SayHello", b"world")| Side | Interceptors |
|---|---|
| client | client_tracing, client_duration, client_prometheus, client_breaker, client_timeout |
| server | server_auth, server_tracing, server_recover, server_stat, server_prometheus, server_breaker, server_shedding, server_timeout |
RpcServerConf and RpcClientConf carry the timeout, both CPU thresholds,
authentication and the per-side flags, so both chains are built from config.
The channel above is in process — it drives a server object directly, which is
what a test wants. zrpc/socket carries the same calls over TCP: Listener puts
a RpcServer on a port with its interceptor chain intact, and SocketChannel
speaks h2c to any gRPC peer.
// native: a listener is a socket
let listener = @socket.Listener::new(rpc)
@async.with_task_group(g => {
g.spawn_bg(() => listener.serve(port=12001))
let ch = @socket.SocketChannel::connect("127.0.0.1", 12001)
let reply = ch.call("/hello.Greeter/SayHello", b"world")
ch.close()
listener.stop()
})Balancer chooses an instance three ways: in turn, by weight, or by power of two
choices over each instance's measured load — sqrt(lag + 1) * (inflight + 1),
where the lag and the success count are moving averages that decay over ten
seconds. A LoadBalancedChannel resolves, picks, dials and calls, and reports the
outcome back so the next pick is informed.
let ch = @moonzero.LoadBalancedChannel::new(
_ => view.endpoints(),
cluster,
"greeter",
balancer=@moonzero.Balancer::p2c(),
)// with `moonbitstack/moonzero/stores/redis` as @redis and `.../stores/cache` as @cache
let store = @redis.RedisStore::new(client, prefix="orders:")
let cache : @cache.Cache[Order] = @cache.Cache::new(
store, encode=order_to_bytes, decode=order_of_bytes, ttl_ms=60_000L,
)
let order = cache.take("42", () => load_order(42))take runs the loader once per key however many callers arrive, and writes the
answer back with a jittered lifetime so a fleet warmed at the same moment does not
expire together. An absent value is not cached.
Three primitives, each a pure decision over an injected clock so every decision is reproducible:
Breakercounts the last ten seconds in forty buckets and sheds a fraction of calls once the failure ratio is past its budget — a struggling backend keeps the load it can still serve.Shedderrefuses work only when the machine is both hot and full: the CPU at or above the threshold, and more calls in flight than the recent throughput and latency can carry.limitoffers a token bucket, a fixed window and a connection count, each in memory or on Redis. The Redis token limiter marks the shared bucket down the moment a call fails and keeps limiting from its local bucket until a probe finds Redis again.
@moonzero.hold keeps an instance registered for as long as its task runs, and a
View follows a service in a task of its own, so a client reads the current set
without waiting. Any registry that implements the discovery contract plugs in
without a change here; the file-backed driver for native hosts ships in discov.
@moonzero.hold(registry, "greeter", @moonzero.Endpoint::new("10.0.0.1", 12001))
let view = @moonzero.View::new()
group.spawn_bg(no_wait=true, () => view.follow(registry, "greeter"))Metrics come from moonp8s and spans from moontel, so the numbers and the traces a
service emits are the ones the rest of the toolchain reads. The engine keeps the
spans its default tracer records — engine.spans() — so a test can assert on a
trace without a collector.
mount_metrics(app, engine.metrics()) // GET /metricsApache-2.0. See LICENSE.