Batteries: cors, compress, log, ratelimit
Four ready-made Middleware implementations, each its own
module, each built by a plain function you hand to Router.@use.
cors/logger sit behind a value-config type + a private @middleware()
- a public, same-named free function (
cors(cfg),logger(cfg)) — one public entry point, the config type stays for its many-field, chainable shape.compression/ratelimitskip the config type entirely — scalar params with defaults are the whole surface (compression(...),ratelimit(...)).
| Battery | Module | Semantics of |
|---|---|---|
| cors | polaris.middleware.cors | tower-http CorsLayer |
| compress | polaris.middleware.compress | tower-http CompressionLayer (gzip only) |
| log | polaris.middleware.log | chi Logger + RequestID + RealIP, folded into one |
| ratelimit | polaris.middleware.ratelimit | chi Throttle / tower::limit, over std’s TokenBucket |
Source: src/middleware/cors.nv,
compress.nv, log.nv,
ratelimit.nv.
cors
test "batteries: cors — preflight answered 204, simple request decorated" {
mut c = Cors.new()
c.allow_origin("https://app.example")
mut r = Router.new()
r.use(cors(c))
r.get("/x", fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, "ok"))!!
ro simple = route_once(r, get_req_h("/x", "Origin", "https://app.example"))
assert(simple.status_code() == 200)
assert(hdr(simple, "Access-Control-Allow-Origin") == "https://app.example")
ro preflight_raw = "OPTIONS /x HTTP/1.1\r\nHost: n\r\nOrigin: https://app.example\r\nAccess-Control-Request-Method: GET\r\n\r\n".bytes()
ro preflight = route_once(r, preflight_raw)
assert(preflight.status_code() == 204)
}
Cors.new() starts strict (nothing allowed); Cors.permissive() allows any
origin/method/header with no credentials (tower-http’s own permissive()
shape). Builder methods: @allow_origin(origin) (repeatable),
@allow_any_origin(), @allow_method(m)/@allow_any_methods(),
@allow_header(name)/@allow_any_headers(), @expose_header(name),
@credentials(bool), @max_age(secs).
Preflight OPTIONS requests (with Access-Control-Request-Method) are
answered entirely by the middleware — 204, next is never called, the
wrapped route’s own 405 fallback never shows. Access-Control-Allow-Origin: *
combined with credentials(true) is spec-forbidden and @middleware()
panics on that config — same as tower-http, on the theory that this
combination is always a caller bug (D325), never live network input.
compress
test "batteries: compress — gzip only above min_size and when the client accepts it" {
mut r = Router.new()
r.use(compression())
consume sb = StringBuilder.new()
mut i = 0
while i < 100 { sb.append("the quick brown fox jumps over the lazy dog; "); i += 1 }
ro big = sb.into_str()
r.get("/x", fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, big))!!
ro accepted = route_once(r, get_req_h("/x", "Accept-Encoding", "gzip"))
assert(hdr(accepted, "Content-Encoding") == "gzip")
ro declined = route_once(r, get_req("/x"))
assert(hdr(declined, "Content-Encoding") == "")
}
compression(min_size: int = 1024, level: CompressLevel = CompressLevel.default())
— defaults to a 1024-byte minimum size and the default gzip level
(compression(min_size: 4096), compression(level: CompressLevel.best()) to
tune; default-valued params are always passed by name). Skipped
automatically —
never a bug to layer it everywhere — when: the response is already
streaming (a chunk producer wins the wire), the body is under min_size,
the response already carries Content-Encoding, the content-type isn’t on
the compressible allowlist (text/* + json/xml/javascript-ish subtypes), or
the client’s Accept-Encoding doesn’t admit gzip. Vary: accept-encoding
is appended whenever the response would be negotiable, even when this
particular answer stays identity, so a shared cache never serves a gzip
body to a client that can’t decode it. Brotli is not offered — the
underlying compress package ships a decoder only, no encoder, so br
negotiation is deliberately absent until one exists.
log
test "batteries: log — one line per request, X-Request-Id propagated" {
mut lines []str = []
ro cfg = AccessLog.new()
with Time = th.fixed_ms(0), Log = capture_log(lines) {
mut r = Router.new()
r.use(logger(cfg))
r.get("/x", fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, "ok"))!!
ro resp = route_once(r, get_req("/x"))
assert(hdr(resp, "x-request-id") == "req-1")
assert(lines.len() == 1)
assert(lines[0] == "[req-1] GET /x -> 200 2B in 0ms")
}
}
One line per request: method, path, status, response body size, wall
duration. AccessLog.new() defaults to request-id on, real-ip off;
lines go through the ambient Log effect — stdout
by default, redirectable in tests via with Log = capture_log(lines) { ... }
(the test above captures into a Vec[str] — no stdout scraping needed in
your own tests either). X-Request-Id is taken from an incoming header
when present and safe, else generated from a per-config counter
(req-1, req-2, …) and echoed back on the response.
@real_ip(true) adds the first X-Forwarded-For hop to the line — off
by default, chi’s own RealIP caveat: that header is client-controlled,
only trust it behind a proxy that overwrites it.
@middleware() carries a Time effect row (it measures wall-clock
duration around the wrapped handler) — tests fix the clock with
with Time = th.fixed_ms(...) (std.testing.handlers) for deterministic
output, exactly as above. Log is NOT in that row: the per-request line is
emitted through a raw Log.info(...) op (not checked under
--strict-effects, see serving.md), so it composes
into the same with Time = ..., Log = ... { ... } block freely.
ratelimit
test "batteries: ratelimit — burst within capacity passes, then 429 + Retry-After" {
with Time = th.fixed_ms(0) {
mut r = Router.new()
r.use(ratelimit(1, 1.0))
r.get("/x", fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, "ok"))!!
assert(route_once(r, get_req("/x")).status_code() == 200)
ro second = route_once(r, get_req("/x"))
assert(second.status_code() == 429)
assert(hdr(second, "retry-after") == "1")
}
}
ratelimit(capacity int, per_sec f64, per_client bool = false) — capacity
tokens of burst, refilled at per_sec tokens/second, wrapping std’s
TokenBucket. Default is one global bucket (chi’s Throttle shape);
ratelimit(2, 1.0, per_client: true) keys separate buckets by the first
X-Forwarded-For hop (same trust caveat as log’s RealIP;
default-valued params are always passed by name — per_client: true, never
a bare third positional). A rejected request gets 429 +
Retry-After: <ceil(1/per_sec)> seconds — the earliest moment a token can
exist again. Like log, building the middleware carries a Time effect row
(the bucket refills against Monotonic.now()); tests fix the clock the same
way.
Known simplification: the bucket is not lock-protected — under true M:N parallelism two fibers can in principle both witness the last token. An over-admission of roughly one token under contention throttles, it does not corrupt state.
Related documents
Full example: examples/04-middleware — log+ratelimit running for real (see also 10-mini-service for log in a bigger service).
- middleware.md — the
Middleware/Router.@usecore these build on - auth.md —
require_jwt/session, two more ready-made middlewares src/middleware/— full source + pin tests for all four