Mongoose.jl

Mongoose.jl is a lightweight, high-performance HTTP & WebSocket server for Julia, built on the Mongoose C library.

Key features:

  • Minimal dependencies — only Mongoose_jll and PrecompileTools
  • Sub-100ms time-to-first-response via PrecompileTools
  • Sync and async server modes with configurable worker pools
  • Dynamic and static (AOT-compatible) routing with typed path parameters
  • Automatic HEAD responses from GET handlers; typed param mismatch returns 404
  • Built-in middleware: CORS, rate limiting, authentication, logging, static files
  • WebSocket support on the same router — with upgrade rejection, frame limits, and idle timeout
  • 503 backpressure when the worker queue is full or too many concurrent timed tasks
  • Optional JSON integration via JSON.jl with explicit setup by the user
  • Full compatibility with juliac --trim=safe for compiled binaries

Installation

] add Mongoose

For JSON support:

] add JSON

Quick Start

using Mongoose

router = Router()

route!(router, :get, "/", req -> Response(Plain, "Hello!"))
route!(router, :get, "/users/:id::Int", (req, id) -> Response(Plain, "User $id"))

server = Async(router; nworkers=4)
start!(server, port=8080, blocking=false)

# When done
shutdown!(server)

Architecture

Mongoose.jl uses a decoupled architecture: a Router defines the routes and handlers, while a Server manages networking, concurrency, and the event loop.

                ┌─────────────────────────────────┐
                │          Router                  │
                │  route!(:get, "/users/:id", h)   │
                │  ws!("/chat", on_message=h)      │
                └────────────┬────────────────────┘
                             │
                     ┌───────▼───────┐
                     │    Server     │
                     │  (event loop) │
                     └───┬───┬───┬──┘
                         │   │   │
                         ▼   ▼   ▼       (Async only)
                       Worker tasks

Server Types

Async — Runs the event loop on a background task and dispatches requests to a pool of worker tasks via channels. Ideal for production APIs and applications where you want non-blocking operation.

server = Async(router;
    nworkers=4,                  # Number of worker tasks
    nqueue=1024,                 # Channel buffer size
    poll_timeout=0,              # Poll timeout (ms)
    max_body=1048576,            # Max request/WS-frame body in bytes (default: 1 MB)
    drain_timeout=5000,          # Graceful shutdown drain timeout (ms)
    request_timeout=0,           # Per-request timeout (0 = disabled)
    ws_idle_timeout=0,         # WebSocket idle timeout in seconds (0 = disabled)
    errors=Dict{Int,Response}()  # Custom responses by status code
)

Server — Runs the event loop on the main thread (blocking). Suitable for simple scripts, or required for AOT compilation with juliac --trim=safe.

server = Server(router;
    poll_timeout=1,              # Poll timeout (ms), default: 1
    max_body=1048576,
    drain_timeout=5000,
    errors=Dict{Int,Response}()
)

Config

All constructor keyword arguments can be consolidated into a Config struct — useful for environment-driven configuration:

config = Config(
    nworkers        = parse(Int, get(ENV, "WORKERS", "4")),
    max_body        = parse(Int, get(ENV, "MAX_BODY", "1048576")),
    request_timeout = parse(Int, get(ENV, "REQ_TIMEOUT", "0")),
    ws_idle_timeout = 60,
    drain_timeout   = 10_000,
)

server = Async(router, config)  # or Server(router, config)
FieldDefaultDescription
poll_timeout1Poll timeout in ms (0 = min latency, high CPU)
max_body1 MBMax request body in bytes; also the WebSocket frame size limit
drain_timeout5000Graceful-shutdown drain period in ms
request_timeout0Per-request timeout in ms; 0 = disabled
ws_idle_timeout0WebSocket idle timeout in seconds; 0 = disabled
nworkers4Worker tasks (Async only)
nqueue1024Channel buffer size (Async only)
errorsDict()Custom Response keyed by status code

Lifecycle

start!(server; host="127.0.0.1", port=8080, blocking=true)
shutdown!(server)  # Graceful: drains in-flight requests, stops workers, frees C resources

Routing

Dynamic Router

Routes are registered with route! using a method symbol (or string) and a path pattern:

router = Router()

# Symbol methods
route!(router, :get, "/hello", req -> Response(Plain, "hi"))
route!(router, :post, "/data", req -> Response(Plain, "ok"))
route!(router, :put, "/update", req -> Response(Plain, "updated"))
route!(router, :patch, "/patch", req -> Response(Plain, "patched"))
route!(router, :delete, "/remove", req -> Response(Plain, "deleted"))

# Other HTTP methods
route!(router, :options, "/alt", req -> Response(Plain, "ok"))

GET routes automatically respond to HEAD requests (body omitted).

Path Parameters

Path segments prefixed with : are captured as parameters. Optional type annotations trigger automatic parsing:

# String parameter (default)
route!(router, :get, "/users/:name", (req, name) -> ...)

# Typed parameters — invalid values return 404 (not 500)
route!(router, :get, "/items/:id::Int", (req, id) -> ...)    # /items/abc → 404
route!(router, :get, "/price/:val::Float64", (req, val) -> ...)

Convenience: Routes on Servers

Routes can be added directly on an already-created server:

server = Async(router)
route!(server, :get, "/health", req -> Response(Plain, "ok"))

Static Router (AOT)

The @router macro generates a compile-time dispatch function with zero dynamic allocation. This is required for juliac --trim=safe:

@router MyApi begin
    get("/", req -> Response(Plain, "Hello"))
    get("/users/:id::Int", (req, id) -> Response(Plain, "User $id"))
    post("/data", req -> Response(Plain, "received"))
    ws("/chat", on_message = msg -> Message("Echo: $(msg.data)"))
end

server = Server(MyApi())
start!(server, port=8080)

Request & Response

Request

Handlers receive a Request:

AccessorReturnsDescription
req.bodyStringRaw request body
get(req.headers, "name", nothing)String or nothingCase-insensitive header lookup
req.queryDict{String,String}Parsed query key/value map
context!(req)Dict{Symbol,Any}Lazily-allocated context dict for middleware data

Response

Construct responses with a format type, body, and optional keyword arguments:

# Format type sets Content-Type automatically
Response(Plain, "Hello!")                    # text/plain, status 200
Response(Json, """{"ok": true}""")          # application/json, status 200
Response(Html, "<p>hi</p>")                 # text/html, status 200
Response(Json, body; status=201)            # custom status
Response(Html, body; status=404)            # custom status
Response(Json, body; headers=["X-Custom" => "value"])  # extra headers

# Binary body (e.g., image or file bytes)
Response(Binary, read("image.png"); status=200)

# Raw form (when you already have a pre-formatted headers string)
Response(200, "Content-Type: image/png\r\n", read("image.png"))

The format type determines the Content-Type header:

FormatContent-Type
Plaintext/plain; charset=utf-8
Htmltext/html; charset=utf-8
Jsonapplication/json; charset=utf-8
Xmlapplication/xml; charset=utf-8
Csstext/css; charset=utf-8
Jsapplication/javascript; charset=utf-8
Binaryapplication/octet-stream

Query String

req.query is a Dict{String,String} of the parsed query parameters. Access values with get:

route!(router, :get, "/search", req -> begin
    q    = get(req.query, "q", "")
    page = something(tryparse(Int, get(req.query, "page", "1")), 1)
    Response(Plain, "Searching: $q page $page")
end)

WebSocket Support

Register WebSocket endpoints with ws!:

ws!(router, "/chat",
    on_message = (msg::Message) -> Message("Echo: $(msg.data)"),
    on_open    = (req::Request) -> begin
        # Return false to reject the upgrade — sends 403 to the client
        auth = get(req.headers, "authorization", nothing)
        auth === nothing && return false
        @info "WS connected" uri=req.uri
    end,
    on_close   = () -> println("disconnected")
)
CallbackSignatureNotes
on_open(req::Request) → AnyCalled before upgrade. Return false to reject (sends 403). Optional.
on_message(msg::Message) → Message | String | Vector{UInt8} | nothingCalled per frame. Return nothing for no reply.
on_close() → AnyCalled after close. Connection already gone — no arguments. Optional.

Production features:

  • Frame size limit — WebSocket frames are subject to the same max_body limit as HTTP requests. Oversized frames close the connection.
  • Idle timeout — set ws_idle_timeout (seconds) on the server to auto-close connections that send no frames within the window. Connections are checked every 5 seconds.
  • Upgrade rejection — return false from on_open to refuse the WebSocket upgrade. The client receives 403 Forbidden and no WebSocket connection is established.
  • Ping/pong — RFC 6455 control frames are handled automatically.

Middleware

Middleware is added with plug! and executes in registration order. Each middleware can short-circuit the request (e.g., return a 401) or pass through to the next handler.

Path-Scoped Middleware

Apply middleware only to specific path prefixes:

plug!(server, bearer(t -> t == "secret"); paths=["/api", "/admin"])
plug!(server, ratelimit(max_requests=10); paths=["/api/expensive"])

Requests to other paths bypass the middleware entirely.

Logger

Logs method, URI, status code, and duration for each request.

plug!(server, logger())                         # Log all requests to stderr
plug!(server, logger(threshold=50))           # Only log requests slower than 50ms
plug!(server, logger(output=open("log.txt","a"))) # Custom output
plug!(server, logger(structured=true))           # JSON log lines

Structured mode emits one JSON object per line:

{"method":"GET","uri":"/users/1","status":200,"duration":1.23,"ts":"2025-01-15T10:30:00"}

CORS

Handles OPTIONS preflight and adds CORS headers to all responses.

plug!(server, cors())                                        # Allow all origins
plug!(server, cors(origins="https://example.com"))            # Specific origin
plug!(server, cors(methods="GET, POST", headers="Authorization", max_age=3600))

Rate Limiting

Fixed-window rate limiter keyed by client IP address.

plug!(server, ratelimit())                                  # 100 req / 60s (defaults)
plug!(server, ratelimit(max_requests=10, window_seconds=30)) # Stricter limits

Returns 429 Too Many Requests with a Retry-After header when exceeded.

Authentication

Bearer token:

plug!(server, bearer(token -> token == "secret-123"))

Returns 401 with WWW-Authenticate: Bearer if missing or invalid scheme, 403 if the validator returns false.

API key:

plug!(server, apikey(header_name="X-API-Key", keys=Set(["key1", "key2"])))

Returns 401 if the header is missing or the key is not in the allowed set.

Prometheus Metrics

The metrics() middleware tracks request counts and latency histograms and exposes a Prometheus scrape endpoint:

plug!(server, metrics())          # exposes GET /metrics
plug!(server, metrics(path="/internal/metrics"))  # custom path

Metrics exposed:

MetricTypeLabels
http_requests_totalcountermethod, status
http_request_duration_secondshistogramle (11 buckets: 5ms–10s)

Static Files

Serve a directory of static assets using the C-level file server (supports Range, ETag, Last-Modified, and gzip):

mount!(server, "public")

Serves index.html for directory requests. Returns 404 for missing files. Path traversal is blocked at the C level.

Error Handling

Custom Error Responses

Register custom Response objects for specific HTTP status codes. The following codes are customizable: 500 (unhandled exception), 413 (body too large), 503 (worker queue full / server overloaded), 504 (request timeout).

using Mongoose, JSON

Mongoose.encode(::Type{Json}, body) = JSON.json(body)

router = Router()
server = Async(router)

# Custom JSON 500 response
fail!(server, 500, Response(Json, Dict("error" => "Internal server error"); status=500))

# Custom 413 response
fail!(server, 413, Response(Json, """{"error":"Request body too large"}"""; status=413))

# Custom 503 response — returned when the worker channel is full or too many
# concurrent timed tasks are in-flight (server overloaded)
fail!(server, 503, Response(Json, """{"error":"Service temporarily unavailable"}"""; status=503))

You can also pass a pre-built dict at construction time:

router = Router()
errors = Dict{Int,Response}(
    500 => Response(Json, """{"error":"Internal error"}"""; status=500),
    413 => Response(Json, """{"error":"Body too large"}"""; status=413),
    503 => Response(Json, """{"error":"Service unavailable"}"""; status=503),
)
server = Server(router; errors=errors)

Custom 404 Pages

For a custom 404 response, add a wildcard catch-all route — no special API needed:

route!(router, :get, "*", req -> Response(Html, read("404.html", String); status=404))

Request Timeout

Set a per-request timeout (in milliseconds) to prevent slow handlers from blocking the server:

server = Async(router; request_timeout=5000)

When a request exceeds the timeout, the server returns 504 Gateway Timeout.

Request ID

Every request is automatically assigned a unique monotonic ID, injected as an X-Request-Id response header. This is useful for correlating logs and debugging:

HTTP/1.1 200 OK
X-Request-Id: 42
Content-Type: text/plain

JSON

JSON support requires JSON.jl. Extend encode once at startup to enable Response(Json, ...) throughout your app:

using Mongoose, JSON

Mongoose.encode(::Type{Json}, body) = JSON.json(body)

route!(router, :post, "/api", req -> begin
    # Parse body manually
    data = JSON.parse(req.body)

    # Send JSON response (auto Content-Type)
    Response(Json, Dict("ok" => true); status=201)
end)

Examples

More comprehensive examples are available on the Examples page.

API Reference

The full API documentation is available on the API page.

License

Distributed under the GPL-2 License.