moonzero
A service framework for MoonBit — config-driven assembly of a moonapi app with middleware into a runnable AsgiApp, the way go-zero does for Go. Backend-agnostic; served by mooncat.
moon add Lfan-ke/moonzero✶ The contract at a glance
let conf = ServiceConf::new(name="greet", port=8888) let server = Server::new(conf, app).use_(logging) server.describe() // "greet listening on 0.0.0.0:8888" @mooncat.serve(server.to_asgi(), port=conf.port) // run it (native)
§Service assembly
ServiceConf (typed config: name, host, port, timeout, log level) + Server tie a moonapi App and a middleware onion into a runnable AsgiApp; logging is a built-in middleware. Served by mooncat.
enum LogLevel
Log verbosity (← go-zero's LogConf.Level), ordered from most to least verbose. Compare follows that order so thresholds can be tested directly.
fn LogLevel::to_string(self : LogLevel) -> String
The canonical lowercase name go-zero uses on the wire.
fn LogLevel::parse(s : String) -> LogLevel
Parse a level name, falling back to Info for anything unrecognised — the same lenient default go-zero applies to a missing/empty level.
struct ServiceConf
Service configuration (← go-zero's ServiceConf): the service name, its bind address, a request timeout, and the log level. timeout_ms is the per-request budget in milliseconds; 0 disables the deadline.
fn ServiceConf::new( name? : String = "app", host? : String = "0.0.0.0", port? : Int = 8888, timeout_ms? : Int = 3000, log_level? : LogLevel = Info) -> ServiceConf
Build a config with sensible defaults (0.0.0.0:8888, 3s timeout, info).
type Middleware = (@moonasgi.AsgiApp) -> @moonasgi.AsgiApp
An AsgiApp transformer — one layer of the middleware onion.
struct Server
A moonzero service: its config plus the assembled application (a moonapi App with any middleware already wrapped around it).
fn Server::new(conf : ServiceConf, app : @moonapi.App) -> Server
Assemble a service from config and a moonapi application.
fn Server::use_(self : Server, mw : Middleware) -> Server
Wrap the current application in another middleware layer (outermost last).
fn Server::to_asgi(self : Server) -> @moonasgi.AsgiApp
The assembled AsgiApp, ready for a server (mooncat) to run.
fn Server::describe(self : Server) -> String
A human-readable description of what this service binds to.
fn logging(inner : @moonasgi.AsgiApp) -> @moonasgi.AsgiApp
A request-logging middleware: prints METHOD path for each HTTP request, then delegates to the wrapped application.
§Middleware set
The onion layers that wrap the app: recovery (500 instead of a panic), cors (Access-Control-* headers), and request_id (x-request-id per request).
fn recovery(inner : @moonasgi.AsgiApp) -> @moonasgi.AsgiApp
Recovery middleware (← go-zero's RecoverHandler): run the wrapped application inside a try, and if it raises, emit a 500 Internal Server Error instead of letting the failure escape to the server. A downstream that has already streamed its response start before raising will produce a second start event; recovery is a last-resort guard, so it always answers rather than trying to detect that race.
struct CorsConf
CORS configuration (← go-zero's cors.Middleware options): the values echoed back in the Access-Control-* preflight/response headers.
fn CorsConf::new( allow_origin? : String = "*", allow_methods? : String = "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS", allow_headers? : String = "Content-Type, Authorization", allow_credentials? : Bool = false, max_age? : Int = 86400) -> CorsConf
Build a permissive CORS config: any origin, the full method set, and a one-day preflight cache. Credentials are off by default, matching go-zero.
fn cors(conf : CorsConf) -> Middleware
CORS middleware (← go-zero's cors.Middleware): wrap the outbound Send so the configured Access-Control-* headers are injected onto every HttpResponseStart, leaving the body and other events untouched.
fn request_id(header? : String = "x-request-id") -> Middleware
Request-ID middleware (← go-zero's trace/x-request-id handling): reuse an inbound x-request-id if the client sent one, otherwise mint a fresh monotonic id, and stamp it onto every response's HttpResponseStart. The counter is captured once per assembly, so ids stay unique across the requests this layer serves.
§Route groups
Group registers a set of moonapi routes under a shared path prefix, so related endpoints are declared without repeating the prefix.
struct Group
A route group (← go-zero's RouteGroup): registers a set of routes on an underlying moonapi.App under a shared path prefix, so related endpoints (e.g. everything under /api/v1) are declared without repeating the prefix.
fn Group::new(app : @moonapi.App, prefix : String) -> Group
Open a group that prefixes every route it registers with prefix on app.
fn Group::prefix(self : Group) -> String
The prefix this group joins onto each registered route.
fn Group::route( self : Group, verb : @moonapi.Method, path : String, handler : @moonapi.ApiHandler, summary? : String = "") -> Unit
Register a route for an explicit method under the group's prefix.
fn Group::get( self : Group, path : String, handler : @moonapi.ApiHandler, summary? : String = "") -> Unit
Register a GET route under the group's prefix.
fn Group::post( self : Group, path : String, handler : @moonapi.ApiHandler, summary? : String = "") -> Unit
Register a POST route under the group's prefix.
fn Group::put( self : Group, path : String, handler : @moonapi.ApiHandler, summary? : String = "") -> Unit
Register a PUT route under the group's prefix.
fn Group::patch( self : Group, path : String, handler : @moonapi.ApiHandler, summary? : String = "") -> Unit
Register a PATCH route under the group's prefix.
fn Group::delete( self : Group, path : String, handler : @moonapi.ApiHandler, summary? : String = "") -> Unit
Register a DELETE route under the group's prefix.
§Typed config loading
Parse a JSON or YAML config string into a ServiceConf: a strict derived FromJson path plus lenient ServiceConf::from_json / from_yaml loaders that fill omitted fields from the new() defaults, the way go-zero's conf.Load applies ,optional/,default= tags.
suberror ConfigErrorA config-loading failure (← go-zero's conf.Load errors): malformed JSON, a non-object root, or a field of the wrong type, with a human-readable reason.
impl @json.FromJson for LogLevel with fn from_json(json, path)
Decode a LogLevel from a JSON string ("debug"/"info"/"error"/ "severe"), reusing LogLevel::parse's lenient fallback to Info. This impl lets ServiceConf's derived FromJson read log_level as a plain string — the way go-zero writes it in YAML/JSON config — instead of a tagged variant.
fn ServiceConf::from_json(src : String) -> ServiceConf raise ConfigError
Load a ServiceConf from a JSON config string, applying go-zero-style defaults for every omitted field (an empty {} yields exactly ServiceConf::new()). This is the lenient loader mirroring go-zero's conf.Load with ,optional/,default= struct tags: unlike the strict derived FromJson — reachable via @json.from_json and requiring every field present — a partial config is filled from the same defaults new() uses. Raises ConfigError on malformed JSON, a non-object root, or a field of the wrong type.
fn ServiceConf::from_yaml(src : String) -> ServiceConf raise ConfigError
Load a ServiceConf from a **YAML** config string — the format go-zero actually ships (etc/*.yaml) — with the same lenient, default-filling semantics as from_json: an empty document yields exactly ServiceConf::new(), and each omitted field falls back to its new() default. The YAML is parsed by the self-built yaml_parse (block mappings, nesting, sequences, scalars, comments) into a Json object, then decoded by the shared field reader — so JSON and YAML configs agree field-for-field. Raises ConfigError on malformed YAML, a non-mapping root, or a field of the wrong type.
§YAML config parser
A self-built minimal-subset YAML parser (block mappings, indentation nesting, sequences, quoted/typed scalars, comments) into a Json value — the etc/*.yaml format go-zero actually ships, complementing the JSON loader.
fn yaml_parse(src : String) -> Json raise ConfigError
Parse a **minimal YAML subset** into a Json value: block mappings (key: value), arbitrary indentation-based nesting, block sequences (- item, including - key: value maps in a list), scalars (quoted/plain strings, integers, floats, true/false, ~/null), and # line comments. Enough of YAML 1.1 to load go-zero-style service config. Flow style ({a: 1}, [1, 2]), anchors/aliases, multi-document streams, and block scalars (|/>) are **not** supported — use JSON for those. Raises ConfigError on a line that is neither a mapping entry nor a sequence item.
§Crypto primitives
Self-built SHA-256 (FIPS 180-4) and HMAC-SHA256 (RFC 2104), verified against NIST/RFC vectors, plus a constant-time byte comparison — the primitives behind JWT HS256, since MoonBit's core ships no crypto.
fn sha256(msg : Bytes) -> Bytes
SHA-256 (FIPS 180-4): hash an arbitrary byte string to a 32-byte digest. A self-built primitive — MoonBit's core ships no crypto — implementing the full message schedule and 64-round compression over 512-bit blocks with the standard length-padding. Verified against the NIST vectors ("", "abc"). The building block for hmac_sha256, and through it for JWT HS256 signing.
fn hmac_sha256(key : Bytes, msg : Bytes) -> Bytes
HMAC-SHA256 (RFC 2104): a keyed message-authentication code over sha256. A key longer than the 64-byte block is hashed first; a shorter key is zero-padded. The message is authenticated as H((K ⊕ opad) ∥ H((K ⊕ ipad) ∥ msg)). Verified against RFC 4231 test case 2. This is the signature function behind JWT HS256.
fn constant_time_eq(a : Bytes, b : Bytes) -> Bool
A constant-time byte-string equality: it inspects every byte of both inputs regardless of where they first differ, so an attacker cannot recover a valid signature byte-by-byte from response timing. Unequal lengths return false immediately (length is not secret). Used to compare JWT signatures.
§JWT (HS256)
base64url plus compact-JWT signing and verification under HS256: jwt_sign / jwt_verify check the signature in constant time and enforce exp/nbf, rejecting the alg:none downgrade — go-zero's token auth core.
fn base64url_encode(data : Bytes) -> String
base64url encoding (RFC 4648 §5, no padding): standard base64 with +// remapped to -/_ and trailing = dropped — the alphabet JWT uses for its header, payload, and signature segments.
fn base64url_decode(s : String) -> Bytes
Decode a base64url string (padding optional) back to bytes, remapping -/_ to +// before decoding. Lenient about missing padding, the way JWT segments are written.
suberror JwtErrorA JWT verification failure (← go-zero's handler.Authorize rejection cases). Every path a bad token can fail on is reported distinctly so callers (and the auth middleware) can log or branch precisely.
fn jwt_sign(claims : Map[String, Json], secret : String) -> String
Sign a claims set as a compact JWT using HS256 (← go-zero's jwt.NewWithClaims(SigningMethodHS256, ...)). The header is fixed to {"alg":"HS256","typ":"JWT"}; claims is serialised as the JSON payload (include exp/iat/nbf/sub/… as ordinary entries); secret is the shared HS256 key. Returns header.payload.signature, each segment base64url-encoded.
fn jwt_verify( token : String, secret : String, now_secs : Int64) -> Map[String, Json] raise JwtError
Verify a compact HS256 JWT and return its claims (← go-zero's handler.Authorize). Checks, in order: three segments; header alg is HS256; the HMAC-SHA256 signature matches (compared in constant time); exp (if present) is strictly after now_secs; nbf (if present) is at or before now_secs. now_secs is the verification time as a Unix timestamp in **seconds** (JWT NumericDate). Raises the matching JwtError on any failure; a tampered payload or signature fails at BadSignature.
§JWT auth middleware
The auth middleware requires every HTTP request to carry a valid Authorization: Bearer <jwt>, rejecting absent/malformed/tampered/expired tokens with 401 before the app runs.
fn jwt_authorized( token : String?, secret : String, now_secs : Int64) -> Bool
Whether a request bearing token is authorised at now_secs: the token verifies against secret under HS256 and is neither expired nor not-yet-valid. A missing token is unauthorised. Exposed as a pure decision so the middleware's accept/reject is testable without driving the transport.
fn auth(secret : String, clock : Clock) -> Middleware
JWT auth middleware (← go-zero's handler.Authorize): require every HTTP request to carry a valid Authorization: Bearer <jwt> header. The token is verified against secret under HS256 at the current time read from clock (milliseconds, converted to the JWT seconds epoch); an absent, malformed, tampered, expired, or not-yet-valid token is rejected with 401 Unauthorized before the wrapped app runs. Non-HTTP scopes (lifespan, websocket) pass through untouched.
§zRPC service groups
An RpcServer (config-driven) registers moonrpc Method handlers by gRPC path and dispatches unary calls, returning Unimplemented for unknown methods; RpcGroup registers a set of methods under one package.Service.
struct RpcServerConf
zRPC server configuration (← go-zero's zrpc.RpcServerConf): the service name, the address it listens on, and a per-call timeout in milliseconds (0 disables it). The registry/etcd fields of go-zero's conf are modelled by the separate discovery layer; this is the transport-facing core.
fn RpcServerConf::new( name? : String = "rpc", host? : String = "0.0.0.0", port? : Int = 8080, timeout_ms? : Int = 2000) -> RpcServerConf
Build an RPC server config with go-zero-style defaults (0.0.0.0:8080, 2s timeout).
fn RpcServerConf::from_json( src : String) -> RpcServerConf raise ConfigError
Load an RpcServerConf from a JSON config string, filling omitted fields from the new() defaults — the lenient loader matching go-zero's ,optional/,default= config tags.
fn RpcServerConf::from_yaml( src : String) -> RpcServerConf raise ConfigError
Load an RpcServerConf from a YAML config string (self-built yaml_parse), with the same default-filling semantics as from_json.
type RpcHandler = (Bytes) -> Bytes
A unary RPC handler: it maps a request message's wire bytes to a response message's wire bytes (the application/grpc+proto payload, sans the length-prefix framing that @moonrpc.encode_message adds). Streaming handlers arrive with the h2 transport; this is the unary shape zRPC registers today.
type ServerStreamHandler = (Bytes) -> Array[Bytes]
A server-streaming handler: one request message in, an ordered sequence of response messages out (each framed as its own length-prefixed gRPC message). Mirrors go-zero's pb.XxxServer server-streaming method, which writes to a grpc.ServerStream instead of returning one reply.
type ClientStreamHandler = (Array[Bytes]) -> Bytes
A client-streaming handler: every request message the client sends is collected, and after the client half-closes the handler returns one reply.
struct BidiStreamHandler
A live bidirectional call (← go-zero's pb.XxxServer bidi method, which reads from and writes to the same grpc.ServerStream): on_message fires once per request message and returns the replies to send right then, so responses interleave with requests; on_end runs after the client half-closes and returns the final replies before the grpc-status trailer. The moonzero-local mirror of @moonrpc.BidiHandler, so callers register bidi methods without naming the transport package.
type BidiStreamFactory = () -> BidiStreamHandler
A factory that mints one BidiStreamHandler per call, so each stream gets its own handler state (← the fresh ServerStream gRPC hands every bidi invocation).
struct RpcServer
A zRPC server (← go-zero's zrpc.Server): config plus a registry mapping each method's gRPC :path (/package.Service/Method) to its handler. Handlers are registered via @moonrpc.Method descriptors — directly or through a RpcGroup — and dispatched by path, mirroring how go-zero registers service implementations on the underlying gRPC server. Unary, server-streaming, and client-streaming methods live in separate registries so one path resolves to exactly one cardinality.
fn RpcServer::new(conf : RpcServerConf) -> RpcServer
Build an empty RPC server from its config.
fn RpcServer::conf(self : RpcServer) -> RpcServerConf
The server's configuration.
fn RpcServer::register( self : RpcServer, desc : @moonrpc.Method, handler : RpcHandler) -> Unit
Register handler for method, keyed by its gRPC path. A later registration for the same path replaces the earlier one.
fn RpcServer::register_server_streaming( self : RpcServer, desc : @moonrpc.Method, handler : ServerStreamHandler) -> Unit
Register a server-streaming handler for method, keyed by its gRPC path.
fn RpcServer::register_client_streaming( self : RpcServer, desc : @moonrpc.Method, handler : ClientStreamHandler) -> Unit
Register a client-streaming handler for method, keyed by its gRPC path.
fn RpcServer::register_bidi_streaming( self : RpcServer, desc : @moonrpc.Method, factory : BidiStreamFactory) -> Unit
Register a bidirectional-streaming handler for method, keyed by its gRPC path. factory runs once per call so each stream gets fresh handler state.
fn RpcServer::group(self : RpcServer, service : String) -> RpcGroup
Open a RpcGroup that registers methods under the fully-qualified package.Service name — go-zero's per-service registration, without repeating the service name on each method.
fn RpcServer::lookup(self : RpcServer, path : String) -> RpcHandler?
Look up the handler registered for a gRPC :path, or None if unregistered.
fn RpcServer::methods(self : RpcServer) -> Array[String]
The gRPC paths of every registered method.
fn RpcServer::has_method(self : RpcServer, path : String) -> Bool
Whether a handler is registered for path.
fn RpcServer::dispatch( self : RpcServer, path : String, request : Bytes) -> Result[Bytes, @moonrpc.Status]
Dispatch a unary call to the handler registered for path, returning the response bytes. An unregistered path yields Err(Unimplemented) — exactly the grpc-status a real gRPC server returns for an unknown method — so a transport can translate the result straight onto the wire.
struct RpcGroup
A per-service registration handle (← go-zero's service registrar closure): binds a set of methods to one package.Service on a shared RpcServer.
fn RpcGroup::service(self : RpcGroup) -> String
The fully-qualified package.Service this group registers under.
fn RpcGroup::register( self : RpcGroup, name : String, handler : RpcHandler) -> Unit
Register a method name on this group's service, building the @moonrpc.Method descriptor and installing handler under its gRPC path. (register, not method — the latter is a reserved word.)
fn RpcGroup::register_server_streaming( self : RpcGroup, name : String, handler : ServerStreamHandler) -> Unit
Register a server-streaming method name on this group's service.
fn RpcGroup::register_client_streaming( self : RpcGroup, name : String, handler : ClientStreamHandler) -> Unit
Register a client-streaming method name on this group's service.
fn RpcGroup::register_bidi_streaming( self : RpcGroup, name : String, factory : BidiStreamFactory) -> Unit
Register a bidirectional-streaming method name on this group's service.
§zRPC over the h2c transport
RpcServer::to_h2 exposes the registered handlers as a moonrpc H2Server, and RpcChannel drives real unary, server/client-streaming, and bidirectional calls over that transport: HPACK-coded HEADERS, length-prefixed DATA frames, and the grpc-status trailer read back off the reply. A BidiCall keeps the stream open both ways — send returns the replies produced right then, close_send runs the server's on_end and reports the final grpc-status.
fn status_of_code(code : Int) -> @moonrpc.Status
Map a numeric grpc-status code back to a @moonrpc.Status. Anything outside the canonical 0–16 range is reported as Unknown, matching how a gRPC client treats an unrecognised code.
fn RpcServer::to_h2(self : RpcServer) -> @moonrpc.H2Server
Build a @moonrpc.H2Server protocol engine from this zRPC server's registered handlers — the transport-facing view of the same registry dispatch reads. Each handler is bound to its gRPC path, so a request arriving over the h2c transport is dispatched to exactly the handler the group registered.
struct RpcChannel
An in-process gRPC channel bound to a server engine — the client half of the h2c transport. A call is carried as the real HTTP/2 frames a socket-backed client would send: an HPACK-coded HEADERS block with the gRPC pseudo-headers, a length-prefixed DATA frame closing the stream, and the grpc-status trailer read back off the engine's reply. The channel's HPACK encoder pairs with the engine's decoder and vice versa, so the dynamic-table state stays in lockstep across every call on the channel.
fn RpcChannel::connect( server : RpcServer, authority? : String = "localhost") -> RpcChannel raise
Open a channel to server over an in-process h2c transport, exchanging the opening SETTINGS the way a real connection does. Client-initiated streams use odd identifiers (RFC 7540 §5.1.1), starting at 1.
fn RpcChannel::call( self : RpcChannel, path : String, request : Bytes) -> Result[Bytes, @moonrpc.Status] raise
Invoke a unary method at path with request as its message payload, driving the call through the h2c engine and returning the reply payload on grpc-status: 0, or the mapped @moonrpc.Status otherwise. request and the returned reply are bare message bytes; the length-prefix framing is applied and stripped by the transport.
fn RpcChannel::call_server_streaming( self : RpcChannel, path : String, request : Bytes) -> Result[Array[Bytes], @moonrpc.Status] raise
Invoke a server-streaming method at path: send the single request message and read back the ordered sequence of reply messages the server produced, or the mapped error @moonrpc.Status if the stream closed with a non-zero grpc-status. On Ok the array holds every message in emission order (possibly empty).
fn RpcChannel::call_client_streaming( self : RpcChannel, path : String, requests : Array[Bytes]) -> Result[Bytes, @moonrpc.Status] raise
Invoke a client-streaming method at path: send every message in requests as its own DATA frame, half-close the stream, and read back the single reply. An empty requests still opens and half-closes the stream, so the handler runs with no messages.
struct BidiCall
A live client-side bidirectional call over the h2c channel (← gRPC's ClientStream): the request stream stays open while messages flow both ways. send writes one request message and returns whatever replies the server produced right then (bidi interleaving — an echo handler answers each message as it arrives); close_send half-closes the request stream, runs the server's on_end, and reports the final grpc-status. The channel's HPACK decoder is advanced across every reply block, so its dynamic table stays in lockstep with the engine's encoder for the life of the call. pending holds DATA octets not yet split into a whole length-prefixed message (a message may straddle two DATA frames under flow control).
fn RpcChannel::open_bidi( self : RpcChannel, path : String) -> BidiCall raise
Open a bidirectional stream to path, sending the request HEADERS without half-closing so the stream stays open for interleaved sends. An unregistered path answers trailers-only UNIMPLEMENTED during this HEADERS feed, which the returned call captures as its status.
fn BidiCall::send(self : BidiCall, msg : Bytes) -> Array[Bytes] raise
Send one request message on the open stream and return the replies the server emitted in response to it (possibly empty). A no-op once the stream is half-closed.
fn BidiCall::close_send( self : BidiCall) -> Result[Array[Bytes], @moonrpc.Status] raise
Half-close the request stream: run the server's on_end, return its final reply messages, and map the grpc-status trailer to Ok/Err. Calling it a second time is an error (Cancelled).
fn RpcChannel::call_bidi_streaming( self : RpcChannel, path : String, requests : Array[Bytes]) -> Result[Array[Bytes], @moonrpc.Status] raise
Drive a whole bidirectional call at path in one shot: send every message in requests (collecting the interleaved replies in order), then half-close and append the on_end replies. The result is every reply message the server produced, in emission order, or the non-zero grpc-status the stream closed with.
§Graceful shutdown
A ShutdownCoordinator that drains in-flight zRPC calls: dispatch_graceful counts each call for its duration, initiate_shutdown makes new calls come back Unavailable while in-flight ones finish, and is_drained reports when the last one has completed.
struct ShutdownCoordinator
A graceful-shutdown coordinator for a zRPC server (← go-zero's proc.AddShutdownListener + gRPC's GracefulStop): once shutdown is initiated the server stops admitting new calls, but calls already in flight are allowed to run to completion. A call brackets its work between begin_call and end_call; begin_call returns false when the server is shutting down, which the transport surfaces as Unavailable — exactly the status a client sees once a server has stopped listening. The server is fully drained once shutdown has been initiated and no calls remain in flight. The counter is plain mutable state, which is safe under moonbitlang/async's cooperative single-threaded scheduling: begin_call/end_call never yield, so the count is only observed at await points between them.
fn ShutdownCoordinator::new() -> ShutdownCoordinator
A coordinator that is serving normally with no calls in flight.
fn ShutdownCoordinator::begin_call(self : ShutdownCoordinator) -> Bool
Admit a new call: register it as in-flight and return true, unless shutdown has been initiated, in which case the call is refused (false) and the count is left untouched.
fn ShutdownCoordinator::end_call(self : ShutdownCoordinator) -> Unit
Mark an admitted call finished, dropping it from the in-flight count. Only call it for a call that begin_call admitted; the count never goes below zero.
fn ShutdownCoordinator::initiate_shutdown( self : ShutdownCoordinator) -> Unit
Begin the graceful shutdown: from now on begin_call refuses new calls while in-flight ones keep running. Idempotent.
fn ShutdownCoordinator::is_shutting_down( self : ShutdownCoordinator) -> Bool
Whether shutdown has been initiated.
fn ShutdownCoordinator::in_flight(self : ShutdownCoordinator) -> Int
The number of calls currently in flight.
fn ShutdownCoordinator::is_drained(self : ShutdownCoordinator) -> Bool
Whether the server is fully drained: shutdown initiated and no call in flight. A supervisor loops on this (yielding between checks) to know the last in-flight RPC has finished and the process may exit.
fn RpcServer::dispatch_graceful( self : RpcServer, coord : ShutdownCoordinator, path : String, request : Bytes) -> Result[Bytes, @moonrpc.Status]
Dispatch a unary call through the shutdown gate: refuse with Unavailable when the server is shutting down, otherwise run the handler and count it as in-flight for the duration so a concurrent shutdown drains behind it. The gate wraps RpcServer::dispatch, so an unregistered path still yields Unimplemented.
§Service registry & discovery
An InMemoryRegistry (etcd-shaped: service -> instance -> endpoint with a store revision) plus RoundRobin/pick_first balancers and resolve_one, the resolve-then-balance step a client runs before a call.
struct Endpoint
A service endpoint (← go-zero's discov target): the host and port an instance listens on, plus a routing weight the balancer honours (default 1).
fn Endpoint::new(host : String, port : Int, weight? : Int = 1) -> Endpoint
Build an endpoint; weight defaults to 1, matching an unweighted instance.
fn Endpoint::address(self : Endpoint) -> String
The host:port dial string.
struct InMemoryRegistry
An in-memory service registry (← go-zero's etcd discov store, minus the network): a two-level map of service -> instance-id -> endpoint and a monotonic revision bumped on every mutation, mirroring etcd's store revision so a watcher could detect change. Instance ids are <service>/<n>, the leaf of the etcd key an instance would lease.
fn InMemoryRegistry::new() -> InMemoryRegistry
A fresh, empty registry at revision 0.
fn InMemoryRegistry::revision(self : InMemoryRegistry) -> Int64
The store revision, incremented on each register/deregister — etcd's mod-revision, the value a watcher compares against to see new state.
fn InMemoryRegistry::register( self : InMemoryRegistry, service : String, endpoint : Endpoint) -> String
Register endpoint under service and return its instance key. Each call mints a distinct key, so two instances of one service coexist, and bumps the revision.
fn InMemoryRegistry::deregister( self : InMemoryRegistry, service : String, key : String) -> Bool
Remove the instance at key from service. Returns true if it existed (and bumps the revision), false if the service or key was unknown.
fn InMemoryRegistry::resolve( self : InMemoryRegistry, service : String) -> Array[Endpoint]
The endpoints registered for service, in registration order.
fn InMemoryRegistry::services(self : InMemoryRegistry) -> Array[String]
Every service name with at least one live instance.
fn resolve_one( registry : InMemoryRegistry, service : String, balancer : RoundRobin) -> Endpoint?
Resolve service on the registry and pick one endpoint with balancer — the resolve-then-balance step a zRPC client runs before each call. An etcd- or consul-backed registry with the same resolve shape drops in unchanged.
struct RoundRobin
A round-robin balancer (← go-zero's roundRobinBalancer) over a resolved endpoint set: successive picks cycle through the instances, spreading load evenly. Holds only a cursor, so it is cheap to keep per client.
fn RoundRobin::new() -> RoundRobin
A round-robin balancer starting at the first instance.
fn RoundRobin::pick( self : RoundRobin, endpoints : Array[Endpoint]) -> Endpoint?
Pick the next endpoint in rotation, or None if the set is empty. The cursor advances modulo the set size, so it stays valid as instances come and go.
fn pick_first(endpoints : Array[Endpoint]) -> Endpoint?
Pick the first endpoint (← gRPC's pick_first), or None if the set is empty. A stable choice that only moves when the head instance goes away.
§Persisted registry & load-balanced client
A PersistentRegistry that adds watch, events_since catch-up, and snapshot/restore through an etcd v3 RangeResponse-shaped JSON document, and a LoadBalancedChannel that resolves a service through the Resolve interface, balances to a live instance, and dials it over the h2c transport.
type Resolve = (String) -> Array[Endpoint]
The resolve half of go-zero's discovery (← discov.Discovery): a function from a service name to its live endpoints. Any store — the in-memory InMemoryRegistry, the persisted PersistentRegistry, or a future etcd/consul client — exposes one via resolver(), so a balancer and the load-balanced channel are written once against the interface and the backing store swaps by swapping the closure.
fn InMemoryRegistry::resolver(self : InMemoryRegistry) -> Resolve
This registry as a Resolve interface value.
fn PersistentRegistry::resolver(self : PersistentRegistry) -> Resolve
This registry as a Resolve interface value.
enum RegistryEvent
A change to the registry keyspace, in etcd v3's watch shape: a Put carries the instance key and its endpoint, a Delete carries the key that went away, and both carry the store revision the change produced. A watcher receives these in revision order, so a client can rebuild the live set incrementally instead of re-resolving the whole service.
fn RegistryEvent::revision(self : RegistryEvent) -> Int64
The store revision a registry event was produced at.
struct PersistentRegistry
A persisted, watchable service registry (← go-zero's etcd discov publisher): the same two-level service -> instance-id -> endpoint store as InMemoryRegistry, plus a per-key mod-revision, an append-only event log for catch-up watchers, live watcher callbacks fired on every mutation, and snapshot/restore that round-trip the whole keyspace through an etcd v3 RangeResponse-shaped JSON document — the bytes a file- or etcd-backed deployment persists and reloads without losing a revision.
fn PersistentRegistry::new() -> PersistentRegistry
A fresh, empty persisted registry at revision 0.
fn PersistentRegistry::revision(self : PersistentRegistry) -> Int64
The store revision, bumped on each register/deregister.
fn PersistentRegistry::register( self : PersistentRegistry, service : String, endpoint : Endpoint) -> String
Register endpoint under service, mint a fresh instance key, bump the revision, and emit a Put. Returns the instance key (<service>/<n>).
fn PersistentRegistry::deregister( self : PersistentRegistry, service : String, key : String) -> Bool
Remove the instance at key from service. On success bumps the revision and emits a Delete; an unknown service or key is a no-op returning false.
fn PersistentRegistry::resolve( self : PersistentRegistry, service : String) -> Array[Endpoint]
The endpoints registered for service, in registration order.
fn PersistentRegistry::services(self : PersistentRegistry) -> Array[String]
Every service name with at least one live instance.
fn PersistentRegistry::keyed_entries( self : PersistentRegistry) -> Array[(String, Endpoint, Int64)]
Every registered instance as (instance-key, endpoint, mod-revision), across all services — the flat keyspace a file- or etcd-backed reader diffs one load against the next to compute the Put/Delete events a change produced.
fn PersistentRegistry::watch( self : PersistentRegistry, on_event : (RegistryEvent) -> Unit) -> Unit
Register a live watcher fired on every subsequent mutation, in revision order (← etcd's Watch with no start revision). To also see changes already applied, replay events_since first.
fn PersistentRegistry::events_since( self : PersistentRegistry, revision : Int64) -> Array[RegistryEvent]
Every event with a revision greater than revision (← etcd's watch start_revision): the catch-up a client replays to reach the current state before switching to live watch callbacks.
fn PersistentRegistry::snapshot(self : PersistentRegistry) -> String
Serialize the whole keyspace as an etcd v3 RangeResponse-shaped JSON document: a header carrying the store revision and the id counter, and one key/value entry per instance carrying its endpoint and mod-revision. This is the exact payload a file- or etcd-backed deployment persists; restore rebuilds an identical registry from it, revisions intact.
fn PersistentRegistry::restore( src : String) -> PersistentRegistry raise ConfigError
Rebuild a registry from a snapshot document, preserving instance keys, their endpoints and mod-revisions, the id counter, and the store revision — so a reloaded registry mints the next key exactly where the persisted one left off and a watcher's events_since(old_revision) still lines up.
enum Balancer
The choice of balancer for a load-balanced channel: round-robin cycles through the resolved instances (spreading load), PickFirst pins the head instance (← gRPC's pick_first).
fn Balancer::round_robin() -> Balancer
A round-robin balancer, cursor at the first instance.
fn Balancer::pick( self : Balancer, endpoints : Array[Endpoint]) -> Endpoint?
Pick one endpoint from a resolved set, or None if it is empty.
struct RpcCluster
The in-process dial table: an endpoint address maps to the RpcServer listening there. It stands in for DNS resolution plus a socket dial in the in-process h2c transport — a real deployment opens a connection to the address instead of looking the server up here, but the resolve→balance→call path above it is the same.
fn RpcCluster::new() -> RpcCluster
An empty cluster.
fn RpcCluster::add( self : RpcCluster, endpoint : Endpoint, server : RpcServer) -> Unit
Bind the server reachable at endpoint's address.
fn RpcCluster::dial( self : RpcCluster, endpoint : Endpoint) -> RpcChannel? raise
Open a channel to the server bound at endpoint, or None if nothing is reachable there (a stale registry entry pointing at a gone instance).
struct LoadBalancedChannel
A load-balanced zRPC client (← go-zero's zrpc.Client over a discovery target): it resolves a service through the Resolver, picks a live instance with the Balancer, dials it on the RpcCluster, and makes the call. The whole resolve→balance→dial→call path runs per call, so instances registering or deregistering between calls take effect on the next one.
fn LoadBalancedChannel::new( resolve : Resolve, cluster : RpcCluster, service : String, balancer? : Balancer = Balancer::round_robin()) -> LoadBalancedChannel
Build a load-balanced channel for service over a Resolve interface, dialing through cluster with balancer (round-robin by default).
fn LoadBalancedChannel::call( self : LoadBalancedChannel, path : String, request : Bytes) -> Result[Bytes, @moonrpc.Status] raise
Make a unary call to path, resolving and balancing to a live instance first.
fn LoadBalancedChannel::call_server_streaming( self : LoadBalancedChannel, path : String, request : Bytes) -> Result[Array[Bytes], @moonrpc.Status] raise
Make a server-streaming call to path, resolving and balancing to a live instance first.
fn LoadBalancedChannel::call_bidi_streaming( self : LoadBalancedChannel, path : String, requests : Array[Bytes]) -> Result[Array[Bytes], @moonrpc.Status] raise
Make a bidirectional-streaming call to path, resolving and balancing to a live instance first, then driving the whole requests exchange to completion.
§Real file-backed registry I/O
The native discov driver (go-zero's discov publisher/subscriber over the filesystem instead of etcd's network): persist_registry writes the snapshot to a real file through moonbitlang/async's fs, FileRegistry loads it back and exposes a resolver(), reload returns the Put/Delete diff since the last load, and watch/watch_once reload on every real filesystem change.
async fn persist_registry( path : String, reg : @moonzero.PersistentRegistry) -> Unit
Persist a registry's whole keyspace to path as its etcd v3 RangeResponse- shaped snapshot document (← go-zero's discov.Publisher writing instance keys into etcd). The file is truncated and rewritten, so it always holds the current state and its revisions; a reader loads or reloads from exactly these bytes.
struct FileRegistry
A file-backed view of a service registry (← go-zero's discov.Subscriber): it holds the last-loaded PersistentRegistry and the file it came from. resolve answers from the in-memory copy; reload re-reads the file and returns the Put/Delete events that the change produced; watch_once/watch block on a real filesystem watcher and reload when the file changes. The reader and the publisher share only the file, exactly as an etcd subscriber and publisher share only the etcd keyspace.
async fn FileRegistry::load(path : String) -> FileRegistry
Read the registry snapshot at path into a fresh FileRegistry. A missing file is treated as an empty registry, so a reader can start before the publisher has written anything and pick the state up on the first reload/watch.
fn FileRegistry::path(self : FileRegistry) -> String
The current file path this registry loads from.
fn FileRegistry::revision(self : FileRegistry) -> Int64
The store revision of the last-loaded snapshot.
fn FileRegistry::resolve( self : FileRegistry, service : String) -> Array[@moonzero.Endpoint]
The endpoints registered for service in the last-loaded snapshot.
fn FileRegistry::resolver(self : FileRegistry) -> @moonzero.Resolve
This file registry as a Resolve interface value, so a balancer and the load-balanced channel drive it exactly as they drive the in-memory registry. The closure reads whatever snapshot was last loaded, so a reload/watch in between calls is reflected on the next resolve.
async fn FileRegistry::reload( self : FileRegistry) -> Array[@moonzero.RegistryEvent]
Re-read the file and adopt it as the current state, returning the RegistryEvent diff from the previously loaded state: a Put for every instance that is new or whose endpoint changed, a Delete for every instance that went away. The events carry the mod-revision the reloaded snapshot recorded, so a client can stay in revision order across reloads.
async fn FileRegistry::watch_once( self : FileRegistry, watcher : @fs.Watcher) -> Array[@moonzero.RegistryEvent]
Block on watcher until the watched directory changes, then reload and return the diff — one watch cycle. The caller owns the @fs.Watcher (built over the directory holding the registry file) and its lifetime, so a single cycle is easy to drive to completion and join; watch loops this for a long-running reader.
async fn FileRegistry::watch( self : FileRegistry, dir : String, on_event : (@moonzero.RegistryEvent) -> Unit) -> Unit
Watch dir (the directory holding the registry file) and invoke on_event for every RegistryEvent produced by every change, indefinitely — the long-running subscribe loop (← go-zero's discov.Subscriber watch goroutine). Runs until the surrounding task group is torn down; drive it with TaskGroup::spawn.
§Metrics
A CounterVec of per-method/route/status request tallies and a cumulative latency Histogram (Prometheus le buckets), wired by the metrics middleware that times each request on the clock.
struct CounterVec
A monotonic counter (← go-zero's metric.CounterVec) partitioned by a label string. Each inc/add accrues against one label (e.g. "GET /ping 200"), so a single vector holds the per-method/route/status request tallies Prometheus scrapes. Counters only ever go up.
fn CounterVec::new() -> CounterVec
A fresh counter vector with no labels seen yet.
fn CounterVec::add( self : CounterVec, label : String, delta : Int64) -> Unit
Add delta to label's count (creating the series on first sight).
fn CounterVec::inc(self : CounterVec, label : String) -> Unit
Increment label's count by one.
fn CounterVec::value(self : CounterVec, label : String) -> Int64
The current count for label, 0 if never touched.
fn CounterVec::total(self : CounterVec) -> Int64
The sum of every label's count — the total number of observations.
fn CounterVec::labels(self : CounterVec) -> Array[String]
The set of labels that have been observed.
struct Histogram
A cumulative histogram (← go-zero's metric.HistogramVec, Prometheus semantics): a sorted list of le (less-than-or-equal) upper bounds and, for each, the count of observations that fell at or below it, plus the running sum and total count. An observation above every bound still lands in the implicit +Inf bucket that count represents.
let default_latency_buckets : Array[Double] = [
The default latency buckets go-zero ships (milliseconds): a request spends most of its time under a second, so the bounds cluster there.
fn Histogram::new( bounds? : Array[Double] = default_latency_buckets) -> Histogram
A histogram over bounds (defaulting to default_latency_buckets). The bounds are taken as given; supply them in ascending order, as Prometheus requires.
fn Histogram::observe(self : Histogram, value : Double) -> Unit
Record one observation: it lands in every bucket whose le bound it does not exceed (cumulative), and updates the sum and count.
fn Histogram::total(self : Histogram) -> Int64
The total number of observations (the +Inf bucket count).
fn Histogram::sum_value(self : Histogram) -> Double
The sum of all observed values (Prometheus _sum).
fn Histogram::bucket_count(self : Histogram, i : Int) -> Int64
The cumulative count in the bucket bounded by bounds[i] — how many observations were <= that bound.
fn Histogram::bounds(self : Histogram) -> Array[Double]
The upper bounds this histogram partitions on.
fn Histogram::mean(self : Histogram) -> Double
The mean of the observations, or 0 when none have been recorded.
struct ServerMetrics
The request metrics an HTTP service exposes (← go-zero's server metrics): a request counter partitioned by method/route/status and a latency histogram. Held by the caller so it can be read out for a /metrics scrape after serving.
fn ServerMetrics::new() -> ServerMetrics
Fresh server metrics: an empty counter and a default-bucket latency histogram.
fn ServerMetrics::requests(self : ServerMetrics) -> CounterVec
The request counter, labelled "<METHOD> <path> <status>".
fn ServerMetrics::latency(self : ServerMetrics) -> Histogram
The request-latency histogram, in milliseconds.
fn metrics(m : ServerMetrics, clock : Clock) -> Middleware
Metrics middleware (← go-zero's prometheus interceptor): time each HTTP request on the shared clock and, when the response starts, count it under "<METHOD> <path> <status>" and record its latency in milliseconds. The record is taken once per request even if a downstream (under a recovery race) emits a second start. Non-HTTP scopes pass through unmeasured.
§Trace-id propagation
W3C traceparent parsing and formatting with SplitMix64-derived trace/span ids, and the tracing middleware that continues an inbound trace or starts a new one and stamps traceparent + x-trace-id onto the response.
fn generate_trace_id(seed : Int64) -> String
A 32-hex-char (128-bit) trace id from seed, mixing two independent words.
fn generate_span_id(seed : Int64) -> String
A 16-hex-char (64-bit) span id from seed.
struct TraceContext
A W3C Trace Context (← go-zero's OpenTelemetry propagation): the 128-bit trace id shared across a request's whole call tree, the 64-bit span id of the current hop, and the 8-bit sampling flags.
fn TraceContext::to_traceparent(self : TraceContext) -> String
Format as a W3C traceparent header value: 00-<32 hex trace-id>-<16 hex span-id>-<2 hex flags>.
fn TraceContext::trace_id(self : TraceContext) -> String
The trace id (the value propagated unchanged down the call tree).
fn TraceContext::span_id(self : TraceContext) -> String
The span id of this hop.
fn parse_traceparent(value : String) -> TraceContext?
Parse a W3C traceparent value, or None if it is malformed. Only the four canonical fields with correct lengths are accepted; the flags default to 0 if unparseable.
fn next_trace_context(inbound : String?, seed : Int64) -> TraceContext
Derive the outgoing trace context for a request: reuse the inbound traceparent's trace id if the client sent a valid one (continuing the distributed trace), else start a new trace, and always mint a fresh child span id from seed. This is the propagation decision, pulled out as a pure function so it is testable without the transport.
fn tracing(header? : String = "x-trace-id") -> Middleware
Trace-id propagation middleware (← go-zero's trace handler): continue the inbound traceparent trace or start a new one, mint a child span, and stamp both traceparent and a convenience x-trace-id onto the response so the id flows to the client and downstream calls. The per-assembly seed counter keeps span ids distinct across the requests this layer serves. Non-HTTP scopes pass through untraced.
§Clock abstraction
A millisecond time source injected into the resilience middlewares so their timing is a pure function of an explicit clock; ManualClock drives them deterministically in tests.
struct Clock
A monotonic time source in **milliseconds**, injected into the resilience middlewares (rate-limit, breaker, timeout) so their timing logic is a pure function of an explicit clock rather than a hidden wall-clock read. go-zero reads timex.Now() directly; because that is neither portable across MoonBit's backends nor testable, moonzero threads the clock as a value — the same pattern Go's clockwork/x/time/rate accept for a Clock.
fn Clock::new(now_ms : () -> Int64) -> Clock
Wrap a now-in-milliseconds thunk as a Clock.
fn Clock::now(self : Clock) -> Int64
The current time in milliseconds, as reported by the wrapped source.
struct ManualClock
A deterministic, hand-advanced clock for tests and for driving the rate-limit / breaker cores without a real time source. Wall time is replaced by an explicit advance, so a token bucket's refill or a breaker's open window can be exercised exactly.
fn ManualClock::new(start? : Int64 = 0) -> ManualClock
A manual clock starting at start milliseconds (default 0).
fn ManualClock::advance(self : ManualClock, delta : Int64) -> Unit
Move the manual clock forward by delta milliseconds.
fn ManualClock::as_clock(self : ManualClock) -> Clock
A Clock view over this manual clock: reading it reflects every advance.
§Rate limiting
A token-bucket limiter (pure counter over the clock) and the rate_limit middleware, which answers 429 Too Many Requests when the bucket is empty.
struct TokenBucket
A token-bucket rate limiter (← go-zero's limit.TokenLimiter, modelled as a pure in-process counter over an explicit clock instead of Redis+Lua). The bucket holds up to capacity tokens and refills continuously at refill_per_ms tokens per millisecond; each admitted request spends one token. Because every decision is a function of (state, now), the limiter is exactly testable without a real clock.
fn TokenBucket::new( rate : Double, burst? : Double = -1.0, now? : Int64 = 0) -> TokenBucket
Build a bucket admitting rate requests per second on average with room for a burst of that many back-to-back (default burst = rate). It starts full at time now. A non-positive rate/burst is clamped to a minimum so the bucket always has a defined capacity.
fn TokenBucket::allow(self : TokenBucket, now : Int64) -> Bool
Try to admit one request at time now: refill, then spend a token if one is available. Returns true when admitted, false when the bucket is empty.
fn TokenBucket::allow_n( self : TokenBucket, n : Double, now : Int64) -> Bool
Try to admit a request costing n tokens at time now. Returns false (spending nothing) when fewer than n tokens are available.
fn TokenBucket::available(self : TokenBucket, now : Int64) -> Double
The (fractional) number of tokens currently available, after refilling to now. Useful for metrics and tests.
fn rate_limit(bucket : TokenBucket, clock : Clock) -> Middleware
Rate-limit middleware (← go-zero's TokenLimitMiddleware): admit each HTTP request against a shared TokenBucket read at clock.now(), answering 429 Too Many Requests when the bucket is empty and otherwise delegating to the wrapped app. The bucket is captured once per assembly, so its state is shared across every request this layer serves. Non-HTTP scopes (lifespan, websocket) pass through untouched.
§Circuit breaker
A closed/open/half-open breaker state machine and the breaker middleware, which trips after K consecutive failures and fails fast with 503 while open.
enum BreakerState
The three states of a circuit breaker (← go-zero's breaker package). A Closed breaker lets traffic through; after too many failures it trips Open and fails fast; once its cool-down elapses it goes HalfOpen and lets a few probe requests through to test recovery.
fn BreakerState::to_string(self : BreakerState) -> String
The state's lowercase name, for logs and metrics.
struct Breaker
A circuit breaker as an explicit open/half-open/closed state machine over a clock (← go-zero's breaker.Breaker; go-zero's default is Google's SRE adaptive algorithm, but the canonical state machine is the faithful, testable core and is exposed here). Trips after max_failures consecutive failures, stays Open for open_ms, then admits up to half_open_max probes; one probe success closes it, one probe failure re-opens it.
fn Breaker::new( max_failures? : Int = 5, open_ms? : Int64 = 5000L, half_open_max? : Int = 1) -> Breaker
Build a closed breaker that trips after max_failures consecutive failures (default 5), stays open for open_ms milliseconds (default 5000), and admits half_open_max probes while half-open (default 1).
fn Breaker::state(self : Breaker) -> BreakerState
The breaker's current state (after any pending Open→HalfOpen transition is applied by allow).
fn Breaker::allow(self : Breaker, now : Int64) -> Bool
Decide whether a request may proceed at time now, advancing the state machine as a side effect: * Closed — always admitted. * Open — rejected until open_ms has elapsed since it tripped, at which point it moves to HalfOpen and admits this request as the first probe. * HalfOpen — admitted while fewer than half_open_max probes are outstanding, otherwise rejected. Returns true to admit, false to fail fast.
fn Breaker::record_success(self : Breaker) -> Unit
Record that an admitted request succeeded. In Closed it resets the failure streak; in HalfOpen a probe success closes the breaker.
fn Breaker::record_failure(self : Breaker, now : Int64) -> Unit
Record that an admitted request failed at time now. In Closed it extends the failure streak and trips Open once it reaches max_failures; a HalfOpen probe failure re-opens the breaker immediately.
fn breaker(b : Breaker, clock : Clock) -> Middleware
Circuit-breaker middleware (← go-zero's breaker interceptor): gate each HTTP request through a shared Breaker. When the breaker admits the request, the outbound HttpResponseStart status is observed — a 5xx (or a raised failure) is recorded as a failure, anything else as a success, driving the state machine. When the breaker is open, the request is failed fast with 503 Service Unavailable without touching the wrapped app. Non-HTTP scopes pass through untouched.
§Timeout & max-bytes
A request Deadline plus the timeout middleware (deadline-enforced on the response path; preemptive cancel is the async boundary) and maxbytes, which rejects over-limit Content-Length with 413.
struct Deadline
A request deadline (← go-zero's timeout middleware's context.WithTimeout): a budget in milliseconds measured from a start instant on the shared clock. A budget_ms <= 0 means "no deadline" and never expires — go-zero's convention for a disabled timeout.
fn Deadline::start(budget_ms : Int64, now : Int64) -> Deadline
Start a deadline of budget_ms milliseconds at time now.
fn Deadline::expired(self : Deadline, now : Int64) -> Bool
Whether the deadline has passed at time now. A non-positive budget never expires.
fn Deadline::remaining(self : Deadline, now : Int64) -> Int64
Milliseconds left before the deadline at time now (never negative); -1 for a disabled (non-positive-budget) deadline, which has no finite remaining.
fn timeout(budget_ms : Int64, clock : Clock) -> Middleware
Timeout middleware (← go-zero's TimeoutHandler): establish a per-request Deadline of budget_ms at clock.now() for the wrapped app. **Async boundary (faithful model).** *Preemptively* aborting an in-flight handler the instant its deadline fires requires racing the handler against a timer and cancelling the loser — in MoonBit that is @async.any([handler, timer]) with structured cancellation, which only runs under the native async runtime and cannot be driven synchronously. What this middleware does portably: it installs the deadline and enforces it on the response path — if the handler blows its budget before emitting its first event, the client receives a 503 timeout (from timeout_events) and the late response is suppressed. The remaining gap (a handler that hangs and never emits) is closed by the race/cancel wired at the async server edge. A budget_ms <= 0 disables the timeout, passing straight through.
fn maxbytes(limit : Int) -> Middleware
Max-bytes middleware (← go-zero's MaxBytesHandler): reject any HTTP request whose declared Content-Length exceeds limit bytes with 413 Payload Too Large, before the wrapped app runs. A limit <= 0 disables the check. Non- HTTP scopes pass through untouched.
§Structured logging
RequestLog captures typed access-log fields (method, path, status, duration, request-id, client-ip, user-agent) and renders one JSON line per request; the structured_logging middleware emits it, timed on the clock.
struct RequestLog
A structured access-log record (← go-zero's logx HTTP access fields). Rather than a free-form line, each request is captured as typed fields and rendered as one JSON object per line — the format go-zero emits under logx and the shape log collectors (ELK, Loki) expect.
fn RequestLog::to_json(self : RequestLog) -> Json
The record as a JSON value with a stable field order, duration_ms rendered as a number of milliseconds.
fn RequestLog::render(self : RequestLog) -> String
The record rendered as a single-line JSON string, ready to write to a log sink.
fn structured_logging(clock : Clock) -> Middleware
Structured-logging middleware (← go-zero's LogHandler): time each HTTP request on the shared clock, capture its method/path/client-ip/user-agent and the response status observed on HttpResponseStart, and print one RequestLog JSON line when the response starts. Non-HTTP scopes pass through without logging.