Production-ready HTTP & WebSocket framework for Julia
Built on the battle-tested Mongoose C library
| Category | Highlights |
|---|---|
| Performance | Sub-100ms TTFR via precompilation. Zero-allocation static router. C-level static file serving with Range, ETag, gzip. |
| Architecture | Sync (Server) and async (Async) modes. Multi-worker pool with backpressure. Per-request timeouts with thread starvation protection. |
| Routing | Trie-based O(1) matching. Typed path parameters (:id::Int). Wildcards (*path). Automatic HEAD from GET handlers. |
| WebSocket | Same port as HTTP. Frame size limits. Idle timeout. Upgrade rejection. Ping/pong (RFC 6455). |
| Middleware | CORS, rate limiting, bearer/API key auth, structured logging, Prometheus metrics, health checks. Path-scoped via paths=. |
| Production | Graceful shutdown with drain. 503 backpressure on overload. Custom error responses. X-Request-Id propagation. |
| AOT | Full juliac --trim=safe support via @router macro. Zero startup time compiled binaries. |
| Minimal | Only Mongoose_jll + PrecompileTools. No HTTP.jl, no Sockets.jl. JSON via optional one-line extension (see JSON). |
] add MongooseFor JSON support:
] add JSONusing Mongoose
router = Router()
route!(router, :get, "/", req -> Response(Plain, "Hello from Mongoose.jl!"))
route!(router, :get, "/users/:id::Int", (req, id) ->
Response(Json, """{"id": $id, "name": "User $id"}""")
)
route!(router, :post, "/echo", req -> Response(Plain, req.body))
server = Async(router; nworkers=4)
start!(server, port=8080, blocking=false)
# Graceful shutdown when done
shutdown!(server)route!(router, :get, "/items", req -> ...)
route!(router, :post, "/items", req -> ...)
route!(router, :put, "/items/:id", (req, id) -> ...)
route!(router, :patch, "/items/:id", (req, id) -> ...)
route!(router, :delete, "/items/:id", (req, id) -> ...)GET routes automatically handle HEAD requests (body stripped, headers preserved).
Append ::Type to a segment for automatic parsing. Invalid values return 404 (e.g. /users/abc when Int is expected):
route!(router, :get, "/users/:id::Int", (req, id) -> ...) # id::Int
route!(router, :get, "/price/:val::Float64", (req, val) -> ...) # val::Float64
route!(router, :get, "/posts/:slug", (req, slug) -> ...) # slug::StringAccess query parameters via req.query, which is a Dict{String,String}:
route!(router, :get, "/search", req -> begin
q = get(req.query, "q", "")
page = something(tryparse(Int, get(req.query, "page", "1")), 1)
limit = tryparse(Int, get(req.query, "limit", ""))
Response(Plain, "Searching: $q, page $page")
end)| Expression | Returns | Description |
|---|---|---|
req.body |
String |
Raw request body |
get(req.headers, "authorization", nothing) |
String | nothing |
Case-insensitive header lookup |
req.query |
Dict{String,String} |
Parsed query map (e.g. Dict("q" => "test", "page" => "2")) |
context!(req) |
Dict{Symbol,Any} |
Lazily-allocated context dict (set by middleware) |
| Type | Model | Best for |
|---|---|---|
Async |
Event loop + N worker tasks via channels | Production APIs |
Server |
Blocking event loop on caller's thread | Scripts, AOT binaries |
# Production: 4 workers, 5s per-request timeout, 4 MB body limit, 60s WS idle timeout
server = Async(router;
nworkers = 4,
nqueue = 1024,
request_timeout = 5000,
max_body = 4 * 1024 * 1024,
ws_idle_timeout = 60,
)
# AOT / simple scripts
server = Server(router)Consolidate all options into a Config struct — particularly useful for environment-driven configuration:
config = Config(
nworkers = parse(Int, get(ENV, "NWORKERS", "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)Register pre-built responses for specific status codes — no function callbacks, fully trim-safe:
fail!(server, 500, Response(Json, """{"error":"Internal error"}"""; status=500))
fail!(server, 413, Response(Json, """{"error":"Body too large"}"""; status=413))
fail!(server, 503, Response(Json, """{"error":"Service overloaded"}"""; status=503))
fail!(server, 504, Response(Json, """{"error":"Timed out"}"""; status=504))
# Custom 404 — add a catch-all route
route!(router, :get, "*", req -> Response(Html, read("404.html", String); status=404))Response(Plain, "Hello!") # text/plain, status 200
Response(Json, """{"ok": true}""") # application/json, status 200
Response(Html, "<p>ok</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 headersThe format type sets the Content-Type header automatically:
| Format | Content-Type |
|---|---|
Plain |
text/plain; charset=utf-8 |
Html |
text/html; charset=utf-8 |
Json |
application/json; charset=utf-8 |
Xml |
application/xml; charset=utf-8 |
Css |
text/css; charset=utf-8 |
Js |
application/javascript; charset=utf-8 |
Binary |
application/octet-stream |
Middleware runs in registration order. Each middleware can inspect and modify the request, short-circuit with a response, or pass through to the next handler.
server = Async(router)
# Structured JSON access logs
plug!(server, logger(structured=true))
# CORS — allow a specific origin
plug!(server, cors(origins="https://myapp.com"))
# Rate limiting — 200 requests per 60s per client IP
plug!(server, ratelimit(max_requests=200, window_seconds=60))
# Auth — scoped to /api routes only
plug!(server, bearer(token -> token == "my-secret"); paths=["/api"])
# API key auth
plug!(server, apikey(header_name="X-API-Key", keys=Set(["key-abc", "key-xyz"])))
# Serve static files from the "public/" directory (C-level, with Range/ETag/gzip)
mount!(server, "public")
# Prometheus-compatible metrics at GET /metrics
plug!(server, metrics())
# Built-in health check at GET /healthz
plug!(server, health())The paths keyword limits a middleware to specific URL prefixes only:
plug!(server, bearer(t -> t == "secret"); paths=["/api", "/admin"])
plug!(server, ratelimit(max_requests=10); paths=["/api/expensive"])Subtype AbstractMiddleware and implement the call operator:
struct RequestTimer <: Mongoose.AbstractMiddleware end
function (::RequestTimer)(req::Request, params, next)
t = time()
res = next()
elapsed = round((time() - t) * 1000, digits=1)
@info "$(req.method) $(req.uri)" status=res.status ms=elapsed
return res
end
plug!(server, RequestTimer())ws!(router, "/chat",
on_message = (msg::Message) -> Message("Echo: $(msg.data)"),
on_open = (req::Request) -> begin
# Return false to reject the upgrade (sends 403)
auth = get(req.headers, "authorization", nothing)
auth === nothing && return false
@info "WS connected" uri=req.uri
end,
on_close = () -> @info "WS disconnected"
)| Callback | Signature | Notes |
|---|---|---|
on_open |
(req::Request) → Any |
Return false to reject upgrade with 403. Optional. |
on_message |
(msg::Message) → Message | String | Vector{UInt8} | nothing |
Called per frame. Return nothing for no reply. |
on_close |
() → Any |
No arguments — connection is already gone. Optional. |
Production features:
- Frame size limit — enforced via
max_body(same limit as HTTP). Oversized frames close the connection. - Idle timeout — set
ws_idle_timeout(seconds) to auto-close stale connections. - Upgrade rejection — return
falsefromon_opento refuse the connection with 403. - Ping/pong — automatic RFC 6455 control frame handling.
💡 Tip: start with ws_idle_timeout=60 and tune based on your client behavior.
Install JSON.jl and extend encode once at startup:
using Mongoose, JSON
Mongoose.encode(::Type{Json}, body) = JSON.json(body)Then use Response(Json, value) anywhere — Content-Type is set automatically:
route!(router, :get, "/users/:id::Int", (req, id) ->
Response(Json, Dict("id" => id, "name" => "User $id"))
)
route!(router, :post, "/users", req -> begin
data = JSON.parse(req.body)
Response(Json, Dict("created" => get(data, "name", "")); status=201)
end)Use the @router macro to generate a zero-allocation, compile-time dispatch function — required for juliac --trim=safe. No dynamic dispatch, no closures, no allocations on the hot path.
# app.jl
using Mongoose
@router MyApp begin
get("/", req -> Response(Plain, "Hello!"))
get("/users/:id::Int", (req, id) -> Response(Plain, "User $id"))
post("/echo", req -> Response(Plain, req.body))
ws("/live", on_message = msg -> Message("Echo: $(msg.data)"))
end
(@main)(ARGS) = begin
server = Server(MyApp)
start!(server, port=8080, blocking=true)
return 0
endCompile and run:
juliac --trim=safe --project . --output-exe myapp app.jl
./myapp # starts instantly — no JIT warmupBy default, Mongoose.jl routes @log_info, @log_warn, and @log_error macros to Julia's native @info, @warn, @error — integrating seamlessly with ConsoleLogger, TeeLogger, and log filtering.
For juliac --trim=safe binaries, set LOG_TRIMMABLE=true to force concrete print()-based logging instead (recommended because Base/CoreLogging dispatch can be trimmed or unreliable in --trim=safe builds):
# Native logging (default, works in JIT and AOT binary)
julia --project app.jl
# For trim-safe binaries, use concrete logging
LOG_TRIMMABLE=true juliac --trim=safe --project . --output-exe binary app.jl
./binary # @log_* routes to concrete print() loggingA complete app with REST API, WebSocket, middleware stack, and custom errors:
using Mongoose, JSON
Mongoose.encode(::Type{Json}, body) = JSON.json(body)
router = Router()
# Health check
route!(router, :get, "/health", req -> Response(Plain, "ok"))
# REST API
route!(router, :get, "/api/users/:id::Int", (req, id) ->
Response(Json, Dict("id" => id, "name" => "User $id"))
)
route!(router, :post, "/api/users", req -> begin
data = JSON.parse(req.body)
Response(Json, Dict("created" => get(data, "name", "")); status=201)
end)
# WebSocket
ws!(router, "/ws",
on_message = (msg::Message) -> Message("""{"ack":true}"""),
on_open = (req::Request) -> @info "WS connected" uri=req.uri,
on_close = () -> @info "WS disconnected"
)
# Custom 404
route!(router, :get, "*", req -> Response(Html, "<h1>Not Found</h1>"; status=404))
# Server
server = Async(router; nworkers=4, request_timeout=10_000, ws_idle_timeout=60)
plug!(server, logger(structured=true))
plug!(server, cors(origins="https://myapp.com"))
plug!(server, ratelimit(max_requests=30, window_seconds=60))
plug!(server, bearer(t -> t == get(ENV, "API_TOKEN", "secret")); paths=["/api"])
mount!(server, "public")
fail!(server, 500, Response(Json, Dict("error" => "Internal error"); status=500))
# blocking=true: start! handles Ctrl+C automatically and shuts down gracefully
start!(server, port=8080)Mongoose.jl serves plain HTTP. For HTTPS in production, terminate TLS at a reverse proxy in front of your Mongoose.jl process.
| Proxy | Notes |
|---|---|
| Caddy | Use automatic certificate management and proxy to 127.0.0.1:8080. |
| nginx | Terminate TLS at the edge and proxy_pass to http://127.0.0.1:8080. |
| Envoy | Terminate TLS at the listener and route to the Mongoose.jl upstream cluster. |
This deployment model keeps certificate rotation, HTTP/2 negotiation, and edge policy out of application code.
Full API reference and examples: AbrJA.github.io/Mongoose.jl
Distributed under the GPL-2 License. See LICENSE for details.