Sign in

    moonproxy

    A native L7 reverse proxy / load balancer runtime: host/path routing, five balancing policies, forwarding header policy, passive health checks and failover.

    http
    reverse-proxy
    load-balancer
    network
    server
    Download zip
    Version
    0.1.1
    License
    Apache-2.0
    Last updated
    13 hours ago
    Downloads
    2

    Dependencies

    #moonproxy

    A native L7 reverse proxy / load balancer runtime for MoonBit — the category of nginx, HAProxy and Traefik. It terminates client HTTP/1.1 connections, matches each request to a route, picks a backend through a pool's balancer, and forwards both directions while cleaning connection-level headers and failing over when a backend is down.

    The forwarding decision logic (routing, balancing, header policy, health and retry) lives in a portable core that compiles to wasm, wasm-gc, js and native; the socket runtime that actually moves bytes is native-only.

    #Features

    • Host and path routing — named hosts beat the wildcard host; exact paths beat prefixes; longer prefixes beat shorter ones.
    • Five load-balancing policies — round-robin, smooth weighted round-robin, least connections, random (with an injectable RNG) and client-IP hash (FNV-1a for stable affinity).
    • Connection-safe header forwarding — strips hop-by-hop and per-connection headers, then adds X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host and X-Real-IP, and rewrites Host to the upstream.
    • Passive health tracking — consecutive failures trip a backend and consecutive successes bring it back; tripped backends are skipped.
    • Failover and retry — a connection failure retries another backend for any method (nothing was sent); a 5xx is retried elsewhere only for idempotent methods; a failure after the request body started is never replayed.
    • Streaming — request and response bodies are piped through readers, so responses are not buffered wholesale.

    #Ecosystem position

    There is no running reverse proxy on mooncakes today. The nearby packages sit at other layers, and moonproxy does not overlap them:

    • LuoYunxin1/moonnginx parses an nginx directive tree; it does not start a server or forward requests.
    • moonbitstack/mooncat, mocket, moonback and the other HTTP servers are application servers (ASGI / uvicorn-style); they serve an app and only rewrite trusted proxy headers, they do not proxy to upstreams.
    • moonhttp is HTTP encoding/decoding with no sockets.
    • moonzero is a Go-zero-style microservice framework whose balancer serves its own internal RPC client, not an edge proxy.

    #Quickstart

    Prerequisite: the MoonBit toolchain (moon). The native runtime needs a C toolchain on the machine — MSVC (cl.exe) on Windows, clang/gcc on Linux and macOS.

    Run the bundled demo. It starts three echo backends and the proxy in one process — no external setup:

    moon run cmd/main --target native

    Then, from another shell:

    # weighted round-robin across the three backends curl -s http://127.0.0.1:18080/ # routed to the api pool curl -s http://127.0.0.1:18080/api/users # a request body is forwarded curl -s -X POST http://127.0.0.1:18080/ -d 'hello'

    The /api pool deliberately includes a closed port; requests still succeed because the proxy fails over to the live backend.

    #Configuration as code

    There is no config file parser in v0.1 — a proxy is built with the core API:

    let web = BackendPool::new("web", policy=WeightedRR)
    .add(Backend::new("10.0.0.1", 8080, weight=2))
    .add(Backend::new("10.0.0.2", 8080, weight=1))
    let config = ProxyConfig::new(host="0.0.0.0", port=80)
    .add_route(Route::prefix("root", "/", web))
    let runtime = @runtime.Runtime::new(config)
    runtime.serve()

    #Build and test

    moon fmt moon check --target all --deny-warn moon build --target all moon test --target all

    The portable core has 25 black-box tests that run on all four targets; the native runtime additionally has an end-to-end test that brings up real backends and a real proxy over localhost.

    #Engineering boundaries (v0.1)

    Being explicit about what this release does not do:

    • The Host sent upstream is the dialed upstream address — the nginx default ($proxy_host). The original host is carried in X-Forwarded-Host.
    • Health is passive (driven by real connection/response outcomes); a periodic active probe is not included yet.
    • The edge speaks HTTP/1.1; HTTP/2 and TLS termination are not in this release.
    • The decision core is portable, but the socket runtime is native-only, since it relies on async's native socket FFI.

    #License

    Apache-2.0

    Backend

    pub struct Backend {
    host : String
    port : Int
    weight : Int
    scheme : Scheme
    healthy : Bool
    failures : Int
    active_conns : Int
    }

    One upstream server instance a pool may forward to. Health and the live connection count mutate as the proxy runs, so those fields are mutable; identity and weight are fixed configuration.

    Backend::address

    fn Backend::address(self : Backend) -> String

    The host:port text used to key and display this backend.

    Backend::host

    fn Backend::host(self : Backend) -> String

    The backend's host (IP or resolvable name).

    Backend::is_healthy

    fn Backend::is_healthy(self : Backend) -> Bool

    Whether this backend may receive traffic.

    Backend::new

    fn Backend::new(host : String, port : Int, weight? : Int, scheme? : Scheme) -> Backend

    Build a backend from an explicit address. weight defaults to 1 and a weight below 1 is treated as 1 so a misconfiguration can never zero out a pool's share. A backend starts healthy and assumed reachable.

    Backend::origin

    fn Backend::origin(self : Backend) -> String

    The origin URL (scheme://host:port) the native client dials.

    Backend::port

    fn Backend::port(self : Backend) -> Int

    The backend's port.

    BackendPool

    pub struct BackendPool {
    id : String
    backends : Array[Backend]
    policy : LBPolicy
    }

    A named group of interchangeable backends and the policy that balances over them. A route points at one pool.

    BackendPool::add

    fn BackendPool::add(self : BackendPool, backend : Backend) -> BackendPool

    Add a backend and return the pool, so construction chains (BackendPool::new("api").add(a).add(b)).

    BackendPool::backends

    fn BackendPool::backends(self : BackendPool) -> Array[Backend]

    The pool's backends (the live array the runtime balances over).

    BackendPool::id

    fn BackendPool::id(self : BackendPool) -> String

    The pool's id.

    BackendPool::new

    fn BackendPool::new(id : String, policy? : LBPolicy) -> BackendPool

    Start an empty pool with the given id and policy.

    BackendPool::policy

    fn BackendPool::policy(self : BackendPool) -> LBPolicy

    The pool's load-balancing policy.

    Balancer

    pub struct Balancer {
    policy : LBPolicy
    cursor : Int
    current : Array[Int]
    }

    Per-pool balancer state.

    Balancer::new

    fn Balancer::new(policy? : LBPolicy) -> Balancer

    Build a balancer for the given policy.

    HealthPolicy

    pub struct HealthPolicy {
    unhealthy_threshold : Int
    healthy_threshold : Int
    }

    The thresholds that move a backend between healthy and unhealthy. A backend goes unhealthy after unhealthy_threshold consecutive failures and returns healthy only after healthy_threshold consecutive successes while it was down — a single lucky probe should not flap a server back into rotation.

    HealthPolicy::new

    fn HealthPolicy::new(unhealthy_threshold? : Int, healthy_threshold? : Int) -> HealthPolicy

    Build a policy; thresholds below 1 are treated as 1.

    HostMatch

    pub(all) enum HostMatch {
    Any
    Host(String)
    }

    How a route matches a request's Host header. Any matches every host; Host is a case-insensitive exact name.

    LBPolicy

    pub(all) enum LBPolicy {
    RoundRobin
    WeightedRR
    LeastConn
    Random
    IPHash
    }

    The load-balancing policy a pool uses to choose among its healthy backends.

    PathMatch

    pub(all) enum PathMatch {
    Exact(String)
    Prefix(String)
    }

    How a route matches a request's path. Exact is the whole request target's path component; Prefix matches any path beneath it.

    ProxyConfig

    pub struct ProxyConfig {
    listen_host : String
    listen_port : Int
    routes : Array[Route]
    }

    The top-level proxy configuration: where to listen and the ordered route table. Routes are matched by specificity, not array order.

    ProxyConfig::add_route

    fn ProxyConfig::add_route(self : ProxyConfig, route : Route) -> ProxyConfig

    Append a route and return the config for chaining.

    ProxyConfig::bind

    fn ProxyConfig::bind(self : ProxyConfig) -> String

    The host:port text the listener binds.

    ProxyConfig::listen_host

    fn ProxyConfig::listen_host(self : ProxyConfig) -> String

    The host the listener binds.

    ProxyConfig::listen_port

    fn ProxyConfig::listen_port(self : ProxyConfig) -> Int

    The port the listener binds.

    ProxyConfig::new

    fn ProxyConfig::new(host? : String, port? : Int) -> ProxyConfig

    Start a config bound to host:port; add routes with add_route.

    ProxyConfig::routes

    fn ProxyConfig::routes(self : ProxyConfig) -> Array[Route]

    The route table.

    Route

    pub struct Route {
    id : String
    host : HostMatch
    path : PathMatch
    pool : BackendPool
    options : RouteOptions
    }

    A routing rule: when host and path match, forward to pool.

    Route::exact

    fn Route::exact(id : String, path : String, pool : BackendPool) -> Route

    A route that matches any host and forwards an exact path to pool.

    Route::new

    fn Route::new(id : String, host : HostMatch, path : PathMatch, pool : BackendPool, options? : RouteOptions) -> Route

    Build a route with an explicit host and path matcher.

    Route::options

    fn Route::options(self : Route) -> RouteOptions

    This route's forwarding options.

    Route::pool

    fn Route::pool(self : Route) -> BackendPool

    The pool this route forwards to.

    Route::prefix

    fn Route::prefix(id : String, prefix : String, pool : BackendPool) -> Route

    A route that matches any host and forwards a path prefix to pool.

    RouteOptions

    pub struct RouteOptions {
    preserve_host : Bool
    forward_headers : Bool
    failover : Bool
    }

    Per-route forwarding knobs. Defaults follow what nginx does.

    RouteOptions::defaults

    fn RouteOptions::defaults() -> RouteOptions

    The mainstream defaults: rewrite Host to the upstream, emit forwarding headers, and fail over to a healthy peer.

    RouteOptions::new

    fn RouteOptions::new(preserve_host? : Bool, forward_headers? : Bool, failover? : Bool) -> RouteOptions

    Build options with explicit toggles; any omitted field takes the default.

    Scheme

    pub(all) enum Scheme {
    Http
    Https
    }

    The scheme used to reach an upstream backend. Only cleartext HTTP is implemented in this version; Https is accepted in the model so a config can name it, and reported as unsupported at the edge rather than dropped.

    Scheme::default_port

    fn Scheme::default_port(self : Scheme) -> Int

    The default port a scheme implies when a backend names none.

    acquire

    fn acquire(backends : Array[Backend], idx : Int) -> Unit

    Record that a request has started using backend idx, for least-connections balancing.

    can_retry_status

    fn can_retry_status(meth : String, status : Int) -> Bool

    Whether a request that saw status from an upstream may be retried on a peer: a retryable status from an idempotent method.

    forward_request_headers

    fn forward_request_headers(incoming : Array[(String, String)], client_ip : String, scheme : String, original_host : String, upstream_host : String, options : RouteOptions) -> Array[(String, String)]

    Build the request headers to send upstream.

    Removes hop-by-hop / connection-specific headers; rewrites Host to the upstream address unless preserve_host; and, when forwarding headers is enabled, appends the peer to any existing X-Forwarded-For chain and fills X-Forwarded-Proto, X-Forwarded-Host and X-Real-IP. Existing values are preserved (a correctly-configured earlier hop is trusted).

    forward_response_headers

    fn forward_response_headers(upstream : Array[(String, String)]) -> Array[(String, String)]

    Filter an upstream response's headers for the client: drop hop-by-hop and framing headers (the server connection re-establishes framing) and pass everything else through unchanged.

    healthy_indices

    fn healthy_indices(backends : Array[Backend]) -> Array[Int]

    The indices of every healthy backend, in array order. Returning indices (rather than copies) lets the runtime select and then mutate the entry.

    host_matches

    fn host_matches(matcher : HostMatch, host : String) -> Bool

    Whether a host matcher accepts the (already normalized, lowercase) host.

    is_idempotent

    fn is_idempotent(meth : String) -> Bool

    Whether a method is idempotent by definition (RFC 9110 §9.2.1): repeating it leaves the server in the same state. PUT is idempotent; POST and PATCH are not.

    is_retryable_status

    fn is_retryable_status(status : Int) -> Bool

    Whether an upstream status is worth retrying on another backend: any 5xx server error. Other statuses are the backend's deliberate answer and are passed through.

    mark_down

    fn mark_down(backends : Array[Backend], idx : Int) -> Unit

    Mark a backend directly down (used when a connection cannot even be established — an unambiguous failure worth a hard removal).

    mark_up

    fn mark_up(backends : Array[Backend], idx : Int) -> Unit

    Mark a backend directly up after a successful active probe.

    normalize_host

    fn normalize_host(host : String) -> String

    Strip the port a Host header often carries (example.com:8080) and lowercase it, so host matching compares names only and is case-insensitive.

    path_matches

    fn path_matches(matcher : PathMatch, path : String) -> Bool

    Whether a path matcher accepts the request path. Exact compares the whole path; Prefix tests a literal prefix (nginx semantics), which matches the prefix itself and everything beneath it.

    pick

    fn pick(balancer : Balancer, backends : Array[Backend], client_key? : String, rand? : (Int) -> Int, exclude? : Array[Int]) -> Int?

    Choose a healthy backend index, or None when nothing is healthy (the runtime then fails over or answers 502).

    client_key feeds IPHash; rand, given (n) and returning a value in [0,n), feeds Random. If a policy's input is missing the choice falls back to round-robin rather than failing.

    record_failure

    fn record_failure(backends : Array[Backend], idx : Int, policy : HealthPolicy) -> Unit

    Count one failed exchange: reset progress toward recovery and, once the failure streak reaches the threshold, pull the backend out of rotation.

    record_success

    fn record_success(backends : Array[Backend], idx : Int, policy : HealthPolicy) -> Unit

    Count one successful exchange: clear the failure streak and, if the backend was being held out, require a full run of successes before re-enabling it.

    release

    fn release(backends : Array[Backend], idx : Int) -> Unit

    Record that a request has finished using backend idx; the count never goes negative.

    route_matches

    fn route_matches(route : Route, host : String, path : String) -> Bool

    Whether a route matches this host and path.

    select_route

    fn select_route(routes : Array[Route], host : String, path : String) -> Route?

    Select the most specific route matching host and path, or None when no route matches (the runtime answers 404 in that case). The host argument is normalized before comparison.

    specificity

    fn specificity(route : Route) -> Int

    A route's specificity score; higher wins. Named hosts outrank any host, and within the same host exact paths outrank prefixes, with longer prefixes more specific than shorter ones.