moon_router

Chi-style HTTP routing for std.http_server.

moon_router.binding

fn json_body(body: string) -> Result<{string: Json}, Error>#

A request body as a JSON object.

Anything that is not an object — a bare number, an array, a truncated document — is one answer, because a client that sent any of them made the same mistake. An empty body is an empty object, which is what a POST with no fields means.

  • body: Raw request body text; an empty body reads as an empty object.
use moon_router.binding
use std.json.Json

struct Update { title: string, tags: [string] }

fn main() {
  let body = binding.json_body(
    "{\"title\":\"moon\",\"zoom\":2,\"active\":true,\"ids\":[1,2]}",
  ).unwrap()
  println(binding.require_non_empty(binding.string_field(body, "title").unwrap(), "title"))
  println(binding.number_field(body, "zoom", 1.0))
  println(binding.bool_field(body, "active", false))
  println(binding.int_array_field(body, "ids"))
  println(binding.require_range(2.0, 1.0, 10.0, "zoom"))
  println(binding.strict_int("42"))
  println(binding.require_strict_int("42", "invalid id", "id"))
  println(binding.typed_body<Update>("{\"title\":\"moon\",\"tags\":[]}"))
}
fn invalid_body() -> Result<{string: Json}, Error>#

The one answer a malformed body gets, named so a route that reads a body some other way can give the same one.

fn typed_body<T>(body: string) -> Result<T, Error>#

A request body as a T, decoded by its codec.

The fourteen extractors below are for a body whose shape is genuinely dynamic. A body with a known shape should declare a struct and come through here instead of calling six of them in a row, each re-stating a field's name, its type, and what counts as a bad value for it.

A decode failure is the same VALIDATION_ERROR envelope the hand-written path produces, so a client cannot tell which route read its body which way. The codec's message names the JSON path it failed at (decode: expected string at $.tags[3].name, found int), and that is what rides the field channel — strictly more than "Invalid JSON" says, and in the same place.

  • body: Raw request body text, decoded by T's codec.
fn string_field(body: {string: Json}, key: string) -> Result<string, Error>#

A string field, or "" when absent. A present field of the wrong type is a bad request rather than a missing one — the client meant to send it.

  • body: The decoded request body object.
  • key: Field name to read.
fn number_field(body: {string: Json}, key: string, fallback: float) -> Result<float, Error>#

A number field as a float. JSON has one number type; the distinction between 3 and 3.0 is a spelling, not a meaning.

  • body: The decoded request body object.
  • key: Field name to read.
  • fallback: Value used when the field is absent.
fn optional_number_field(body: {string: Json}, key: string) -> Result<Optional<float>, Error>#

A number field that may genuinely be absent, which is different from being zero: an unsent zoomLevel leaves the stored one alone.

  • body: The decoded request body object.
  • key: Field name to read.
fn bool_field(body: {string: Json}, key: string, fallback: bool) -> Result<bool, Error>#

Reads an optional Boolean field from a JSON object.

  • body: The decoded request body object.
  • key: Field name to read.
  • fallback: Value used when the field is absent.
fn int_field(body: {string: Json}, key: string, fallback: int) -> Result<int, Error>#

Reads an optional integer field from a JSON object.

  • body: The decoded request body object.
  • key: Field name to read.
  • fallback: Value used when the field is absent.
fn int_array_field(body: {string: Json}, key: string) -> Result<Optional<[int]>, Error>#

A field that must be an array of integers — a list of ids. Absent and null are none, which a caller distinguishes from an empty array meaning "clear the list".

  • body: The decoded request body object.
  • key: Field name to read.
fn string_array_field(body: {string: Json}, key: string) -> Result<[string], Error>#

Reads a required string-array field from a JSON object.

  • body: The decoded request body object.
  • key: Field name to read.
fn require_range(value: float, low: float, high: float, field: string) -> Result<int, Error>#

Refuses a value outside its range, naming the field so a UI can point at the input that is wrong rather than at the form.

  • value: Value to check.
  • low: Lowest value accepted.
  • high: Highest value accepted.
  • field: Field name carried in the validation error.
fn number_text(value: float) -> string#

A bound as the message spells it. to_string already prints an integral float without a decimal point, which is what interpolating a JavaScript number does.

  • value: Bound to render into a message.
fn require_non_empty(value: string, field: string) -> Result<string, Error>#

Rejects an empty field value with a validation error.

  • value: Field value to check.
  • field: Field name carried in the validation error.
fn strict_int(value: string) -> Optional<int>#

An optional sign and digits, and nothing else — no whitespace, no decimal point, no empty value. none when the text is not exactly an integer.

Stricter than a permissive parse on purpose, and the strictness is not any one language's: strconv.Atoi, Rust's str::parse and JavaScript's Number() all refuse the same texts. " 12" and "12abc" are not twelve, and reading them as twelve is how a typo in a URL silently operates on the wrong row.

  • value: Text that must be exactly an optional sign followed by digits.
fn require_strict_int(value: string, message: string, field: string) -> Result<int, Error>#

strict_int where a bad value is a 400 naming the field, rather than a silent absence — the shape a path segment wants, since /tags/{id} with a malformed id names no row at all.

  • value: Text that must be exactly an optional sign followed by digits.
  • message: Message carried in the validation error.
  • field: Field name carried in the validation error.

moon_router.envelope

fn success(json_body: string) -> Response#

A 200 response with a JSON body. (ok is a moon keyword, hence success.)

use moon_router.envelope
use std.http.Response
use std.json.Json

struct Project { name: string, year: int }

fn main() {
  println(envelope.success("{\"ok\":true}").status)
  println(envelope.ok_status(201, "{\"created\":true}").status)
  println(envelope.ok_json(Json.Obj({"name": Json.Str("moon")})).body)
  println(envelope.ok_typed(Project { name: "moon", year: 2026 }).body)
  println(envelope.no_content().status)
  println(envelope.from_result(ok("{}")).status)
}
fn ok_status(status: int, json_body: string) -> Response#

A JSON response with an explicit status.

  • status: HTTP status for the response.
  • json_body: Already-serialized JSON text.
fn use_error_body(render: fn(int, string, string, string) -> string)#

Installs the application's error body rendering. Set once, at boot.

A service replacing one written in another language has a shape it must match byte for byte, and it is usually not this one: Go's AppError is flat, and a Go service encodes it with HTML escaping and a trailing LF. A closure rather than a fixed set of shapes, because the encoder is the application's too.

  • render: Called as render(status, code, message, field) to produce the error body.
fn clear_error_body()#

Restores this package's nested envelope.

fn nested_error_body(status: int, code: string, message: string, field: string) -> string#

This package's shape: {"error":{"message":…,"code":…,"field":…,"status_code":…}}.

  • status: HTTP status for the response.
  • code: Machine-readable error code.
  • message: Human-readable message.
  • field: Field the error is about; omitted when empty.
fn flat_error_body(status: int, code: string, message: string, field: string) -> string#

The flat shape a Go AppError emits: {"error":…,"code":…,"field":…}, with the status carried by the response line alone.

  • status: HTTP status for the response.
  • code: Machine-readable error code.
  • message: Human-readable message.
  • field: Field the error is about; omitted when empty.
fn app_error(status: int, code: string, message: string, field: string) -> Response#

The AppError envelope: field is omitted when empty.

  • status: HTTP status for the response.
  • code: Machine-readable error code.
  • message: Human-readable message.
  • field: Field the error is about; omitted when empty.
use moon_router.envelope
use std.http.Response

fn main() {
  let carried = envelope.field_message("title is required", "title")
  let (message, field) = envelope.split_field(carried)
  println(message + ":" + field)
  println(envelope.status_for("NOT_FOUND"))
  println(envelope.code_for("SqlError"))
  println(envelope.app_error(400, "VALIDATION_ERROR", message, field).body)
  let failed: Result<string, Error> = err.NOT_FOUND("missing")
  println(envelope.from_result(failed).status)
}
fn field_separator() -> string#

The separator a carried field name hides behind. U+0001, which no message a person writes contains.

fn field_message(message: string, field: string) -> string#

A message that carries field with it, for err.VALIDATION_ERROR(..).

  • message: Human-readable message.
  • field: Field name carried alongside it.
fn split_field(message: string) -> (string, string)#

Splits a carried message back into (message, field).

fn status_for(name: string) -> int#

The HTTP status for an error name.

The names are the conventional ones a service raises, and everything unrecognised is 500 — a failure nobody classified is an internal one, which is the safe direction: it is better to under-report a client error than to tell a client its request was at fault when the server was.

  • name: The failure's error name.
fn code_for(name: string) -> string#

The wire code for an error name: a classified name passes through, anything else reports as INTERNAL_ERROR.

This is the leak guard. An unclassified failure's name is an internal detail — SqlError, WriteFailed, whatever the module happened to call it — and a client that could read it would be reading the server's internals. OPERATION_FAILED is the exception because it is deliberately raised as a 500 a client is meant to see named.

  • name: The failure's error name.
fn from_error(failure: Error) -> Response#

A failure as an envelope response: status and code derived from its name, and a carried field name taken back out of its message.

  • failure: The failure to render.
fn from_result(outcome: Result<string, Error>) -> Response#

Either half of a handler's Result, rendered.

The shape almost every handler ends in, and the reason from_error exists: a handler computes a Result<string, Error> and this decides whether that is a 200 with a body or a status derived from the failure's name.

  • outcome: A handler result whose success half is already-serialized JSON text.
fn use_json_body(render: fn(Json) -> string)#

Installs the application's success-body encoding. Set once, at boot.

  • render: Called with the response tree to produce the body bytes.
fn clear_json_body()#

Restores std.json.to_string.

fn encode_body(value: Json) -> string#

value as response bytes, through the installed encoder.

  • value: The tree to render.
fn json(status: int, value: Json) -> Response#

A JSON response carrying an encoded Json value.

  • status: HTTP status for the response.
  • value: The tree to render into the body.
fn ok_json(value: Json) -> Response#

json at 200. (ok is a moon keyword, hence the longer name.)

  • value: The tree to render into the body.
fn no_content() -> Response#

204 with no body: an action that succeeded and has nothing to report.

fn from_json_result(outcome: Result<Json, Error>) -> Response#

Either half of a handler's Result<Json, Error>, rendered.

  • outcome: A handler result whose success half is a Json tree.
fn typed<T>(status: int, value: T) -> Response#

A typed JSON response with an explicit status: T encoded by its codec.

Total, because to_value<T> is — a T this cannot encode is a compile error at the call site naming the part of the type that is the problem, not a Result every handler has to thread.

The bytes are written straight out of the value when nothing has installed a body renderer, which is the ordinary case. Going through to_value<T> would materialize the whole document as a Json tree so that to_string could read it once and throw it away — measured at 50 ms of tree against 14 ms of direct encode for a 1.5 MB listing (overhaul plan E2). A service that has installed a renderer still gets the tree, because a renderer's argument is a Json by signature.

  • status: HTTP status for the response.
  • value: The value to encode by T's codec.
fn ok_typed<T>(value: T) -> Response#

typed at 200.

  • value: The value to encode by T's codec.
fn from_typed_result<T>(outcome: Result<T, Error>) -> Response#

Either half of a handler's Result<T, Error>, rendered.

  • outcome: A handler result whose success half is a T.

moon_router.files

fn content_type_for(path: string) -> string#

The media type an extension names, application/octet-stream when it names nothing this table knows.

Deliberately short. It covers what a service actually streams — video, images, the text a browser renders — and a service with a format of its own passes the content type to serve directly rather than growing this.

  • path: Path whose extension names the media type.
use moon_router.files
use moon_router.router
use std.http_server.Request

fn main() {
  println(files.content_type_for("poster.webp"))
  println(files.content_type_for("video.mp4"))
  println(files.is_read_method("HEAD"))
  let request = router.detached_request(
    "GET",
    "/video",
    "Range: bytes=100-199",
    "",
  )
  println(files.header(request, "range"))
  println(files.range_start(request, 1000))
}
fn is_read_method(method: string) -> bool#

Whether method asks to read: GET or HEAD.

A file route answers both, and only both — a HEAD is a GET whose body the client does not want, which is why serve_file handles it rather than the route.

  • method: HTTP method, uppercase.
fn header(req: Request, name: string) -> Optional<string>#

Request header name, or none when it was not sent.

std.http_server.get_header answers "" for both absent and empty, and a range parser has to tell those apart: an empty Range is a malformed request, an absent one is an ordinary read.

  • req: Request to read from.
  • name: Header name, matched case-insensitively.
fn range_start(req: Request, size: int) -> int#

The first byte a Range header asks for, or 0 when there is no usable one.

For a log line or a metric — what part of the file a client jumped to. serve_file does the real range handling, including the 416 this cannot express.

  • req: Request that may carry a Range header.
  • size: Size of the resource in bytes.
fn serve(ctx: Ctx, path: string, content_type: string) -> Response#

Serves path on the socket and answers router.responded().

A missing file goes through the application's own not-found rendering rather than the plain-text 404 serve_file writes for an unopenable path: a service whose 404 is an envelope should answer file routes the same way, and a client cannot tell which route it asked for from the shape of the answer it gets.

  • ctx: Request context, used for the connection and the not-found rendering.
  • path: File to serve from disk.
  • content_type: Value of the Content-Type header.
fn serve_path(ctx: Ctx, path: string) -> Response#

serve with the content type read off the extension.

  • ctx: Request context, used for the connection and the not-found rendering.
  • path: File to serve from disk; its extension picks the content type.

moon_router.middleware

fn logging() -> fn(Ctx, fn(Ctx) -> Response) -> Response#

Structured request logging via std.log: one info record per request with method, path, status, and wall duration.

struct CorsOptions#

CORS configuration; permissive() gives the common defaults.

allow_origin: string

Value of Access-Control-Allow-Origin; * allows any.

allow_methods: string

Methods a preflight is told are allowed, comma-separated.

allow_headers: string

Request headers a preflight is told are allowed, comma-separated.

expose_headers: string

Response headers a browser lets a script read. Empty sends none.

The one nobody remembers until a media player breaks: without Content-Range here, a cross-origin range request succeeds and the script still cannot see where in the file it landed.

allow_credentials: bool

Whether the browser may send credentials. A * origin and credentials are mutually exclusive by spec, so a service that sets this names an origin.

max_age: int

How long a browser may cache the preflight answer, in seconds.

preflight_unmatched: bool

Answer OPTIONS from these rules alone, before matching.

A preflight to a path no route claims must not be a 404, or the browser refuses the real request that would have followed — and a preflight to a path that is claimed under another verb must not be a 405 for the same reason. Off by default: the package's existing behaviour is chi's, where a preflight runs the matched node's chain.

Installed by cors_async through router.use_preflight.

fn permissive() -> CorsOptions#

* origin, the common verb set, Content-Type, Authorization headers.

expose_headers, allow_credentials and preflight_unmatched are all off.

use moon_router.envelope
use moon_router.middleware
use moon_router.router
use std.http.Response
use std.http_server.Request

fn main() {
  let app = router.new()
  let policy = middleware.permissive()
  app.use_mw(middleware.logging())
  app.use_mw(middleware.cors(policy))
  app.get("/health", |_ctx| envelope.success("{\"ok\":true}"))
  let response = app.dispatch(router.detached_request("GET", "/health", "", ""))
  println(middleware.with_cors(response, policy).headers)
}
fn with_cors(response: Response, options: CorsOptions) -> Response#

response with this policy's CORS headers appended.

The middleware does this itself; it is public for the answers that never reach a middleware — a router's fallbacks run outside the onion by construction, and those are exactly the four responses (a 404, a wrong verb, a crash, a timeout) a browser most needs to be allowed to read.

  • response: The response the headers are appended to.
  • options: The policy whose headers are written.
fn cors(options: CorsOptions) -> fn(Ctx, fn(Ctx) -> Response) -> Response#

CORS middleware: answers OPTIONS preflights with 204 plus the Access-Control-* headers (the router routes a method-miss through the node's middleware chain, which is what makes this fire), and appends Access-Control-Allow-Origin to ordinary responses.

  • options: The policy the middleware enforces.
struct RequestRecord#

One request, as a logging sink sees it. path is router.path_of — the query dropped, and a trailing slash with it — so two spellings of the same resource are one line in a log and one label in a metric.

method: string

HTTP method, uppercase.

path: string

Request path with the query string and any trailing slash dropped.

status: int

Status the router answered with.

ms: int

Wall-clock duration of the request in milliseconds.

struct LoggingOptions#

How logging_async decides what to record and where to send it.

quiet: fn(string) -> bool

Paths logged only when they fail. A UI polling a job list emits thousands of lines an hour that bury everything else.

sink: [fn(RequestRecord) -> void]

Where a record goes. Zero or one, the package's idiom for an optional function (Route.async_handler, Router.fallbacks): empty is std.log, which is what a service with no opinion wants. A service that renders readable lines for a person passes its own.

fn log_all() -> LoggingOptions#

Log everything, through std.log.

fn log_record(record: RequestRecord)#

A structured record per request, the level chosen by the status: a 4xx or 5xx is a warning, everything else is information.

  • record: The finished request to write.
fn logging_async(options: LoggingOptions) -> fn(Ctx, fn(Ctx) -> Future<Response>) -> Future<Response>#

The async twin of logging(): one record per request, with the quiet-path rule and the caller's sink.

This is the one an ordinary service actually reaches. Every route that talks to a database is registered with *_async, and use_mw_async feeds a chain of its own, so logging() never fires for any of them.

  • options: Which paths stay quiet, and where records go.
use moon_router.middleware
use moon_router.router
use std.http.Response

fn main() {
  let app = router.new()
  let logging = middleware.log_all()
  app.use_mw_async(middleware.logging_async(logging))
  app.use_mw_async(middleware.cors_async(middleware.permissive()))
}
fn cors_async(options: CorsOptions) -> fn(Ctx, fn(Ctx) -> Future<Response>) -> Future<Response>#

The async twin of cors(): a preflight answered with 204 and the rules, and the response headers on everything else.

With preflight_unmatched set this also installs a router.use_preflight responder, because a preflight to a path no route claims never reaches a middleware chain at all — there is no matched node whose onion could run. That is a side effect on the process rather than on a value, and it is deliberate: the alternative is every service repeating the four-line OPTIONS short-circuit ahead of dispatch_async, which is exactly the code this replaces.

  • options: The policy the middleware enforces.

moon_router.multipart

const MAX_FIELD_BYTES: int#

How much of a small part is read before it is refused.

Generous for what these fields actually carry — a filename, a tag id list, a preview config object — and far below anything that would matter, which is the point: a field with no ceiling is a way to exhaust memory over a form that looks ordinary.

const CHUNK_BYTES: int#

How much is pulled from the socket at a time.

const MAX_UPLOAD_BYTES: int#

How much of a whole small upload — a thumbnail, a sprite sheet, an I-frame manifest — is read before it is refused.

struct BufferedPart#

One part read fully into memory.

name: string

The part's form field name.

filename: string

The uploaded filename, or empty for a non-file part.

bytes: string

The part's whole body.

impl BufferedPart#
fn text(self: BufferedPart) -> string#

The part's bytes as text. The same value — a moon string is a byte string — named for how the caller means to read it.

fn read_part(part: MultipartPart, limit: int) -> Result<string, Error>#

Reads one part into memory, refusing anything over the field ceiling.

  • part: The part currently under the multipart reader.
  • limit: Byte ceiling; a larger part is refused rather than buffered.
fn skip_part(part: MultipartPart)#

Drains a part without keeping it. Used for the parts a route does not want: the body still has to be consumed to reach the next boundary.

  • part: The part to drain so the reader can reach the next boundary.
fn stream_part(part: MultipartPart, sink: fn(string) -> Result<int, Error>) -> Result<int, Error>#

Streams one part through sink, returning how many bytes passed through.

The sink is called with each chunk as it arrives, so a video part is written to storage as it is uploaded rather than assembled in memory first.

  • part: The part to stream.
  • sink: Called with each chunk as it arrives; its counts are summed.
fn buffered_parts(request: Request, limit: int) -> Result<{string: BufferedPart}, Error>#

Every part of a request, read into memory under the field ceiling.

For the three upload routes whose parts are all small — a thumbnail, a sprite sheet, an I-frame manifest and its segment. The video upload does not use this; it streams.

A repeated name keeps the last part, which is what reading them into a map does in the original.

  • request: Streaming request whose body is multipart/form-data.
  • limit: Byte ceiling applied to each part.
use moon_router.multipart
use std.http_server.Request

fn fields(request: Request) -> Result<string, Error> {
  let parts = multipart.buffered_parts(request, multipart.MAX_FIELD_BYTES)?
  let title = if parts.has("title") { parts["title"].text() } else { "" }
  ok(title)
}

fn main() {
  println(multipart.extension_of("C:\\uploads\\Poster.WEBP"))
  println(multipart.CHUNK_BYTES)
  println(multipart.MAX_UPLOAD_BYTES)
}
fn extension_of(filename: string) -> string#

The extension of an uploaded filename, lowercased, including the dot.

Read from the last dot in the last path segment: an upload from a Windows client may carry a whole path, and a directory with a dot in its name must not be mistaken for a file type.

  • filename: Uploaded filename, possibly carrying a whole Windows path.

moon_router.router

struct Ctx#

Per-request context handed to handlers: the raw request plus decoded route params. The raw req passes through untouched so plan-55 streaming handlers (respond_stream, serve_file) keep working.

req: Request

The request exactly as it arrived, so streaming handlers can still answer on the socket.

params: {string: string}

Route parameters decoded from the path, by their pattern names.

impl Ctx#
fn param(self: Ctx, name: string) -> string#

The decoded value of route param name, or "".

  • name: Route parameter name, as the pattern spells it.
fn query(self: Ctx, name: string) -> string#

The last value given for query key name, or "".

Last rather than first, which is what URLSearchParams.get answers and what a client repeating a key expects to override with.

  • name: Query key to read.
fn query_all(self: Ctx, name: string) -> [string]#

Every value given for query key name, in order — getAll. A key appearing with no = counts, with an empty value.

  • name: Query key to read.
fn query_or(self: Ctx, name: string, fallback: string) -> string#

query, but falling back when the key is missing or empty.

The distinction matters for a query string a browser built: a form field left blank arrives as ?limit=, and a caller that wanted a default wants it there too.

  • name: Query key to read.
  • fallback: Value used when the key is missing or empty.
fn has_query(self: Ctx, name: string) -> bool#

Whether query key name was given at all, however empty its value. ?force and ?force= are both present; a missing key is not.

  • name: Query key to read.
fn query_int(self: Ctx, name: string, fallback: int) -> int#

Query key name as an integer, falling back when it is missing, empty or not a number.

Falling back rather than failing is the decision every hand-rolled version of this makes: a bad ?limit= should still render the page. A route that wants a malformed value refused is asking a different question — binding.require_strict_int is that one.

  • name: Query key to read.
  • fallback: Value used when the key is missing, empty, or not a number.
fn query_int_clamped(self: Ctx, name: string, fallback: int, low: int, high: int) -> int#

query_int, held inside [low, high].

The pagination primitive: a limit a client may raise but not past what the server will do, and not below one row. fallback is clamped too, so a default outside the range cannot escape through the missing-key path.

  • name: Query key to read.
  • fallback: Value used when the key does not parse; it is clamped too.
  • low: Lowest value the result may take.
  • high: Highest value the result may take.
fn query_bool(self: Ctx, name: string, fallback: bool) -> bool#

Query key name as a boolean.

1/true/yes/on and 0/false/no/off in any case; anything else is fallback. A bare ?force with no value is true, because a flag a client spelled at all is one it meant to set — which is also what has_query already reports about it.

  • name: Query key to read.
  • fallback: Value used when the key is missing or unreadable.
fn path_of(target: string) -> string#

The path a request target names: the query string dropped, and a trailing slash with it, because /api/videos/ names the resource /api/videos does.

Matching does not need it — the trie splits on segments, so both spellings already reach the same route — but everything that reads a target back does: a log line, a metric label, a dispatcher looking at the raw target off the socket. Exported because those readers are otherwise each one find("?") away from getting it subtly wrong.

  • target: Raw request target, query string and all.
fn query_pairs(target: string) -> [(string, string)]#

key=value pairs from a request target's query string, percent-decoded with + read as a space — which is what a query string spells it as and what url_decode deliberately does not do, since a + in a path segment is a plus.

  • target: Raw request target, query string and all.
fn query_decode(text: string) -> string#

url_decode with + read as a space.

  • text: Query-string text to percent-decode, reading + as a space.
struct Route#
method: string

HTTP method this route answers, uppercase.

pattern: string

Normalized full path pattern, group prefix included.

handler: fn(Ctx) -> Response

The synchronous handler, or a placeholder for an async route.

async_handler: [fn(Ctx) -> Future<Response>]

The async handler, when the route was registered with one. Zero or one element, because moon has no Optional<fn> worth spelling: a route has exactly one handler and this array says which kind it is.

A route registered this way can only be dispatched by dispatch_async — a synchronous dispatch has nothing to await with. It answers 500 rather than silently running the placeholder.

middleware: [fn(Ctx, fn(Ctx) -> Response) -> Response]

The synchronous onion captured at registration, outermost first.

async_middleware: [fn(Ctx, fn(Ctx) -> Future<Response>) -> Future<Response>]

The async middleware chain, captured the same way middleware is. Only meaningful on an async route: a sync onion cannot wrap an awaiting leaf.

timeout_ms: int

The group's timeout class in milliseconds; 0 means none.

struct Fallbacks#

How a router answers a request no handler produced a response for.

Supplied by an application whose wire shape is not this package's — a port whose 404 is Go's text/plain body, say, or one that deliberately answers 404 for a wrong verb because the router it replaced did not distinguish. Absent means the envelope, which is what every existing caller gets.

The Ctx handed to each is the request as it arrived, with whatever params were bound before the answer was decided (none, for a 404).

not_found: fn(Ctx) -> Response

No route matched the path at all.

method_not_allowed: fn(Ctx, string) -> Response

The path matched but the method did not; allow is the header value this package would have sent. An application that answers 404 for a wrong verb returns its own not-found here.

failed: fn(Ctx, AsyncError) -> Response

A handler failed. The failure is logged by the router before this runs, so this decides only what the client sees.

timed_out: fn(Ctx, int) -> Response

A handler outlived its timeout class, in milliseconds.

struct TrieNode#
children: {string: int}

Literal segments to their child node index.

param_child: int

Node index reached by a {name} segment, or -1.

param_name: string

Name a {name} segment binds to.

wild_child: int

Node index reached by a {rest...} segment, or -1.

wild_name: string

Name a {rest...} segment binds to.

methods: {string: int}

Methods registered at this node, each to its route index.

struct Router#

A routing group: value handle over shared storage plus this group's prefix, middleware chain, and timeout class.

routes: [Route]

The route table, shared by every group handle over it.

nodes: [TrieNode]

The matching trie arena, shared the same way.

prefix: string

This group's normalized path prefix.

middleware: [fn(Ctx, fn(Ctx) -> Response) -> Response]

This group's synchronous onion, outermost first.

async_middleware: [fn(Ctx, fn(Ctx) -> Future<Response>) -> Future<Response>]

This group's async onion; only async routes see it.

fallbacks: [Fallbacks]

Zero or one, like a route's async handler: the application's own fallback rendering, or this package's envelope.

timeout_ms: int

This group's timeout class in milliseconds; 0 means none.

fn new() -> Router#

Creates an empty router.

use moon_router.envelope
use moon_router.router
use std.http.Response
use std.http_server.Request

fn main() {
  let app = router.new()
  let api = app.group("/api")
  api.get("/projects/{id}", |ctx| {
    envelope.success("{\"id\":\"" + ctx.param("id") + "\"}")
  })
  api.post("/projects", |_ctx| envelope.ok_status(201, "{}"))

  let request = router.detached_request(
    "GET",
    "/api/projects/moon?limit=20&draft=true",
    "",
    "",
  )
  let response = app.dispatch(request)
  println(response.status)
  println(app.find("GET", "/api/projects/moon").params)
  println(app.surface())
  println(router.path_of(request.path))
  println(router.query_pairs(request.path))
}
fn url_decode(text: string) -> string#

Percent-decodes a path segment; + is left alone (path, not query, semantics). Invalid escapes pass through literally.

  • text: Path segment to percent-decode; + is left as a plus.
impl Router#
fn route(self: Router, method: string, pattern: string, handler: fn(Ctx) -> Response)#

Registers handler for method at pattern (joined onto the group prefix). Pattern segments: literals, {name} params, and a trailing {rest...} wildcard capturing the remaining path.

  • method: HTTP method to answer, uppercase.
  • pattern: Path pattern, joined onto the group prefix; {name} captures a segment and a trailing {rest...} captures what is left.
  • handler: Called with the request context; its response is sent back.
fn route_async(self: Router, method: string, pattern: string, handler: fn(Ctx) -> Future<Response>)#

The async twin of route. The handler may await, so a route registered this way can only be dispatched by dispatch_async — a synchronous dispatch has nothing to await with, and answers 500 naming the mistake rather than a 404 the caller would chase in the route table.

  • method: HTTP method to answer, uppercase.
  • pattern: Path pattern, joined onto the group prefix; {name} captures a segment and a trailing {rest...} captures what is left.
  • handler: Called with the request context; the future it returns is awaited for the response.
fn get(self: Router, pattern: string, handler: fn(Ctx) -> Response)#

Registers a synchronous GET route.

  • pattern: Path pattern, joined onto the group prefix; {name} captures a segment and a trailing {rest...} captures what is left.
  • handler: Called with the request context; its response is sent back.
fn get_async(self: Router, pattern: string, handler: fn(Ctx) -> Future<Response>)#

Registers an asynchronous GET route.

  • pattern: Path pattern, joined onto the group prefix; {name} captures a segment and a trailing {rest...} captures what is left.
  • handler: Called with the request context; the future it returns is awaited for the response.
fn get_stream(self: Router, pattern: string, handler: fn(Ctx) -> Response)#

Registers a GET route whose handler answers on the socket itself and returns responded() — a file served through files.serve, or a hand-built respond_stream.

It registers exactly like get and dispatches exactly like get — including the HEAD that comes with every GET, which is what lets serve_file answer a player asking how big a video is. The name exists for the two readers who cannot see the handler: a person, who learns that this route's bytes never pass through a Response, and Router.surface, which now reports a pattern that used to live in a dispatcher outside the router and in a hand-kept list beside it.

  • pattern: Path pattern, joined onto the group prefix; {name} captures a segment and a trailing {rest...} captures what is left.
  • handler: Answers on the socket itself and returns responded().
fn post_async(self: Router, pattern: string, handler: fn(Ctx) -> Future<Response>)#

Registers an asynchronous POST route.

  • pattern: Path pattern, joined onto the group prefix; {name} captures a segment and a trailing {rest...} captures what is left.
  • handler: Called with the request context; the future it returns is awaited for the response.
fn put_async(self: Router, pattern: string, handler: fn(Ctx) -> Future<Response>)#

Registers an asynchronous PUT route.

  • pattern: Path pattern, joined onto the group prefix; {name} captures a segment and a trailing {rest...} captures what is left.
  • handler: Called with the request context; the future it returns is awaited for the response.
fn delete_async(self: Router, pattern: string, handler: fn(Ctx) -> Future<Response>)#

Registers an asynchronous DELETE route.

  • pattern: Path pattern, joined onto the group prefix; {name} captures a segment and a trailing {rest...} captures what is left.
  • handler: Called with the request context; the future it returns is awaited for the response.
fn patch_async(self: Router, pattern: string, handler: fn(Ctx) -> Future<Response>)#

Registers an asynchronous PATCH route.

  • pattern: Path pattern, joined onto the group prefix; {name} captures a segment and a trailing {rest...} captures what is left.
  • handler: Called with the request context; the future it returns is awaited for the response.
fn head_async(self: Router, pattern: string, handler: fn(Ctx) -> Future<Response>)#

Registers an asynchronous HEAD route.

  • pattern: Path pattern, joined onto the group prefix; {name} captures a segment and a trailing {rest...} captures what is left.
  • handler: Called with the request context; the future it returns is awaited for the response.
fn post(self: Router, pattern: string, handler: fn(Ctx) -> Response)#

Registers a synchronous POST route.

  • pattern: Path pattern, joined onto the group prefix; {name} captures a segment and a trailing {rest...} captures what is left.
  • handler: Called with the request context; its response is sent back.
fn put(self: Router, pattern: string, handler: fn(Ctx) -> Response)#

Registers a synchronous PUT route.

  • pattern: Path pattern, joined onto the group prefix; {name} captures a segment and a trailing {rest...} captures what is left.
  • handler: Called with the request context; its response is sent back.
fn delete(self: Router, pattern: string, handler: fn(Ctx) -> Response)#

Registers a synchronous DELETE route.

  • pattern: Path pattern, joined onto the group prefix; {name} captures a segment and a trailing {rest...} captures what is left.
  • handler: Called with the request context; its response is sent back.
fn patch(self: Router, pattern: string, handler: fn(Ctx) -> Response)#

Registers a synchronous PATCH route.

  • pattern: Path pattern, joined onto the group prefix; {name} captures a segment and a trailing {rest...} captures what is left.
  • handler: Called with the request context; its response is sent back.
fn head(self: Router, pattern: string, handler: fn(Ctx) -> Response)#

Registers a synchronous HEAD route.

  • pattern: Path pattern, joined onto the group prefix; {name} captures a segment and a trailing {rest...} captures what is left.
  • handler: Called with the request context; its response is sent back.
fn group(self: Router, prefix: string) -> Router#

Returns a nested group at prefix. The group shares this router's routes and inherits a copy of its middleware chain and timeout class, so later use calls on either side do not leak across.

  • prefix: Path prefix every route of the group hangs under, joined onto this router's own.
fn use_fallbacks(self: Router, fallbacks: Fallbacks) -> Router#

Installs this router's fallback rendering. Applies to the whole shared route storage, not just this group: a 404 has no group to belong to.

  • fallbacks: The 404, 405, failure, and timeout renderings this service answers with.
fn use_mw_async(self: Router, mw: fn(Ctx, fn(Ctx) -> Future<Response>) -> Future<Response>)#

Appends an async middleware to this group's chain. Only async routes see it: a sync onion cannot wrap a leaf that awaits, so the two chains are kept apart rather than silently mixed.

  • mw: Called as mw(ctx, next); it must await next to reach the handler.
fn use_mw(self: Router, mw: fn(Ctx, fn(Ctx) -> Response) -> Response)#

Appends a middleware to this group's chain (first used = outermost). Applies to routes registered on this group afterwards.

  • mw: Called as mw(ctx, next); the first one added is the outermost.
fn with_timeout(self: Router, ms: int) -> Router#

Returns a view whose routes carry timeout class ms (0 = none), enforced by dispatch_async.

  • ms: Timeout class for routes registered on the view; 0 means none.
fn dispatch(self: Router, req: Request) -> Response#

Synchronous dispatch: match, run the route's middleware onion and handler. Timeouts/recovery are dispatch_async features.

  • req: The request to match and answer.
fn handler(self: Router) -> fn(Request) -> Response#

A fn(Request) -> Response closure over this router, pluggable straight into std.http_server.serve.

const MAX_DRAINED_BODY_BYTES: int#

A guard, not a limit any real request approaches: JSON routes take small documents, and anything genuinely large should be arriving as multipart and being streamed.

fn drained_body(req: Request) -> string#

The request body as a string, read off the socket if it is still there.

accept_stream deliberately leaves the body unread, so an upload route can pull a multi-gigabyte file in windows rather than materialise it. Every other route reads req.body — so on an accept_stream server, something has to do that read, and without it every POST and PUT looks to every handler like it carried nothing. The failure is quiet and confusing: an API answers "name is required" to a request that supplied a name.

Two cases are left alone, and both are the point. A body already present is returned untouched (an accept server buffered it). A multipart/ body is not read at all, because those bytes belong to the route that streams them.

  • req: The request whose body to read; a multipart/ body is deliberately left alone.
fn is_connected(req: Request) -> bool#

Whether req arrived over a socket this process still holds.

_handle is the accepted descriptor, so a real request always has one; a request built by detached_request carries -1 and nothing may be read off it. Anything reaching for the connection — draining a body, streaming a response, serving a file — has to ask first.

  • req: The request to test for a live descriptor.
fn detached_request(method: string, target: string, headers: string, body: string) -> Request#

A Request that never came off a socket.

Routing is the half of a service that is worth testing hardest and the half that needs a live connection least: which route a path picks, what the params decode to, what the middleware puts on the way out, and what answers a request nothing matched are all decided before a byte moves. Opening a listener to ask those questions makes the suite slow, flaky and unable to run two cases at once, so this builds the request instead.

target is the raw request target, query string included, exactly as a client would have sent it. headers is the \n-joined block, and body is the body the handler will see — a detached request has no socket to drain one from, so what is passed here is all there is. A route that streams (serve_file, respond_stream) still needs a real connection; dispatch and dispatch_async do not.

  • method: HTTP method, uppercase.
  • target: Raw request target, query string included.
  • headers: Header block joined with newlines.
  • body: Body the handler will see; there is no socket to drain one from.
fn responded() -> Response#

The sentinel a streaming handler returns after responding through the raw request (respond_stream/serve_file): accept-loop servers must skip respond for it.

fn is_responded(response: Response) -> bool#

Whether response is the already-responded sentinel.

  • response: The value a handler returned.
fn use_preflight(responder: fn(Request) -> Response)#

Answers every OPTIONS request from responder, ahead of route matching.

The gap it closes: a preflight to a path no route claims matches nothing, so there is no captured middleware chain to run and no CORS middleware can fire — the browser reads a 404 and refuses the real request that would have followed. A path claimed only under another verb has the same problem with a 405. Neither is a routing question; both are answered by the CORS rules alone, which is why the answer belongs ahead of resolve.

Process-wide rather than per-Router, like envelope.use_error_body: a preflight policy is a property of the service that is listening, and the alternative is threading it through every group handle. middleware.cors_async installs it from CorsOptions.preflight_unmatched; a service with a different rule can install its own.

  • responder: Answers every OPTIONS request, ahead of route matching.
fn clear_preflight()#

Restores matched-chain preflight handling.

fn not_found_for(ctx: Ctx) -> Response#

This service's not-found rendering for ctx, or the package envelope when no application installed one.

files.serve is the caller that matters: a file route that answers 404 should answer it in the shape every other route does, and the plain-text body std.http_server.serve_file writes for an unopenable path is nobody's wire contract.

  • ctx: The request context the 404 is rendered for.
fn clear_fallbacks()#

Forgets the recorded fallbacks; the package envelope answers again.

struct FindResult#

Result of Router.find: status is 200 (route matched — pattern and params describe it), 405 (allow lists the methods), or 404.

status: int

200 when a route matched, 405 when only the method was wrong, 404 otherwise.

pattern: string

The pattern that matched, for a 200.

params: {string: string}

The parameters that pattern bound.

allow: string

The Allow header value, for a 405.

impl Router#
fn find(self: Router, method: string, path: string) -> FindResult#

Matches method path without dispatching — the testable core of the routing semantics.

  • method: HTTP method to match, uppercase.
  • path: Request path, without a query string.
fn surface(self: Router) -> [string]#

Every registered METHOD pattern pair, sorted.

The surface a contract test compares against a recorded expectation, so a route silently added, removed or renamed fails the build rather than a client. find answers what one request resolves to; this answers what the router accepts at all, which is the question a contract asks.

async fn dispatch_async(r: Router, req: Request) -> Responseasync#

Async dispatch for accept-loop servers: the sync semantics plus recovery (a failed handler becomes a 500 envelope, logged at error level with the detail — the client never sees internal error text) and per-group timeouts (a 503 envelope once the route's timeout class expires; the handler task keeps running to completion, mirroring chi where the work is not preempted).

  • r: The router to match against.
  • req: The request to answer.

moon_router.serve

const DEFAULT_DRAIN_MS: int#

How long a shutdown waits for in-flight work before giving up on it.

const STOP_SIGNALS: [string]#

The signals a service stops on. Ctrl-C and the one an orchestrator sends.

async fn accept_loop(listener: int, serve_one: fn(Request) -> Future<int>) -> intasync#

One connection at a time, each served on its own task.

Never returns on its own: the caller cancels it. That is not a shortcut — there is no other way to leave an accept that no client will complete.

  • listener: Listener handle to accept on.
  • serve_one: Called with each accepted request, on a task of its own.
async fn wait_for_stop() -> stringasync#

Blocks until the process is asked to stop, and answers which signal did it.

async fn serve_with_shutdown(listener: int, serve_one: fn(Request) -> Future<int>, drain: fn() -> Future<int>, drain_ms: int, idle_timeout_ms: int) -> boolasync#

Serve until a stop signal arrives, then shut down in the one order that works.

Accept in a spawned task; wait; cancel the acceptor before closing the listener, since a parked accept cannot notice a closed socket it is already inside; close the listener so nothing new is taken; then run the caller's drain against drain_ms. Answers true if the drain finished inside its deadline, false if the deadline won — a caller that wants to exit non-zero on a hung shutdown has the answer rather than having to time it itself. A second stop signal also returns false. Waiting for that signal keeps the default hybrid clock paced during the drain, including when its work is blocked in native I/O. An explicitly pinned virtual clock stays virtual.

idle_timeout_ms is how long a finished keep-alive connection may sit in the pool before the server closes it; 0 keeps std.http_server's 60 s default, the same way drain_ms of 0 keeps DEFAULT_DRAIN_MS. A service behind a proxy that holds connections open longer than this will reconnect more often than it needs to, and one that never times them out accumulates descriptors for clients that walked away.

  • listener: Listener handle to accept on.
  • serve_one: Called with each accepted request, on a task of its own.
  • drain: Run after the listener closes, to finish in-flight work.
  • drain_ms: How long the drain may take; 0 keeps DEFAULT_DRAIN_MS.
  • idle_timeout_ms: How long an idle keep-alive connection may sit in the pool; 0 keeps std.http_server's 60 s default.
use moon_router.router
use moon_router.serve
use moon_router.router.Router
use std.http.Response
use std.http_server.Request

async fn serve_one(app: Router, request: Request) -> int {
  let response = await router.dispatch_async(app, request)
  if !router.is_responded(response) {
    std.http_server.respond(request, response)
  }
  0
}

async fn drain() -> int { 0 }

async fn main() {
  let app = router.new()
  app.get("/health", |_ctx| std.http_server.text(200, "ok"))
  let listener = std.http_server.listen("127.0.0.1", 8080)
  println(await serve.serve_with_shutdown(
    listener,
    |request| serve_one(app, request),
    drain,
    serve.DEFAULT_DRAIN_MS,
    60_000,
  ))
}