← Документация · nv-lang/nova-polaris

Батарейки: cors, compress, log, ratelimit

Четыре готовые реализации Middleware, каждая — свой модуль, каждая строится простой функцией, которую передают в Router.@use. cors/logger стоят за типом-конфигом + приватным @middleware() + публичной одноимённой свободной функцией (cors(cfg), logger(cfg)) — одна публичная точка входа, тип-конфиг остаётся ради своей многополевой, чейнящейся формы. compression/ratelimit вовсе обходятся без типа-конфига — скалярные параметры со значениями по умолчанию и есть вся поверхность (compression(...), ratelimit(...)).

БатарейкаМодульСемантика
corspolaris.middleware.corstower-http CorsLayer
compresspolaris.middleware.compresstower-http CompressionLayer (только gzip)
logpolaris.middleware.logchi Logger + RequestID + RealIP, слитые в один
ratelimitpolaris.middleware.ratelimitchi Throttle / tower::limit, поверх TokenBucket из std

Исходник: 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() стартует строго (ничего не разрешено); Cors.permissive() разрешает любой origin/метод/заголовок без credentials (форма tower-http’шного permissive()). Методы-билдеры: @allow_origin(origin) (повторяемо), @allow_any_origin(), @allow_method(m)/@allow_any_methods(), @allow_header(name)/@allow_any_headers(), @expose_header(name), @credentials(bool), @max_age(secs).

Preflight-запросы OPTIONSAccess-Control-Request-Method) отвечаются полностью самим промежуточным обработчиком204, next не вызывается вовсе, собственный 405-fallback обёрнутого route’а никогда не показывается. Access-Control-Allow-Origin: * вместе с credentials(true) запрещено спецификацией, и @middleware() на такой конфигурации паникует — как и tower-http, на том основании, что такая комбинация всегда является багом вызывающего кода (D325), а не живым сетевым вводом.

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()) — по умолчанию минимальный размер 1024 байта и дефолтный уровень gzip (compression(min_size: 4096), compression(level: CompressLevel.best()) для настройки; параметры со значением по умолчанию всегда передаются по имени). Пропускается автоматически — никогда не баг наложить его везде — когда: ответ уже стримится (chunk-продюсер выигрывает провод), тело меньше min_size, ответ уже несёт Content-Encoding, content-type не в allowlist для сжатия (text/* + json/xml/javascript-подобные подтипы), либо клиентский Accept-Encoding не допускает gzip. Vary: accept-encoding добавляется всегда, когда ответ мог бы быть согласуемым, даже если конкретно этот ответ остался identity — чтобы разделяемый кэш никогда не отдал gzip-тело клиенту, который не умеет его раскодировать. Brotli не предлагается — пакет compress, лежащий в основе, поставляет только декодер, без энкодера, поэтому согласование br намеренно отсутствует, пока энкодер не появится.

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")
    }
}

Одна строка на запрос: метод, путь, статус, размер тела ответа, wall-clock длительность. AccessLog.new() по умолчанию — request-id включён, real-ip выключен; строки идут через ambient эффект Log — по умолчанию в stdout, перенаправляемо в тестах через with Log = capture_log(lines) { ... } (тест выше перехватывает их в Vec[str] — скрейпинг stdout не нужен и в ваших собственных тестах). X-Request-Id берётся из входящего заголовка, если он присутствует и безопасен, иначе генерируется из счётчика конкретного конфига (req-1, req-2, …) и эхом возвращается в ответе. @real_ip(true) добавляет в строку первый хоп X-Forwarded-For — по умолчанию выключено, тот же предупреждающий комментарий, что у chi’шного RealIP: этот заголовок контролируется клиентом, доверяйте ему только за прокси, который его перезаписывает.

@middleware() несёт эффект-ряд Time (он измеряет wall-clock длительность вокруг обёрнутого обработчика) — тесты фиксируют часы через with Time = th.fixed_ms(...) (std.testing.handlers) для детерминированного вывода, как показано выше. Log в этом ряду НЕТ: строка запроса эмитится сырым опом Log.info(...) (не проверяется под --strict-effects, см. serving.md), поэтому он свободно комбинируется в одном with Time = ..., Log = ... { ... }.

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 токенов burst’а, пополняемых со скоростью per_sec токенов в секунду, поверх TokenBucket из std. По умолчанию — один глобальный bucket (форма chi’шного Throttle); ratelimit(2, 1.0, per_client: true) разделяет buckets по первому хопу X-Forwarded-For (тот же предупреждающий комментарий про доверие, что и у RealIP в log; параметры со значением по умолчанию всегда передаются по имени — per_client: true, никогда голым третьим позиционным). Отклонённый запрос получает 429 + Retry-After: <ceil(1/per_sec)> секунд — ближайший момент, когда токен может появиться снова. Как и log, построение промежуточного обработчика несёт эффект-ряд Time (bucket пополняется относительно Monotonic.now()); тесты фиксируют часы тем же способом.

Известное упрощение: bucket не защищён локом — под настоящей M:N-параллельностью два fiber’а в принципе могут одновременно увидеть последний токен. Over-admission примерно на один токен под конкуренцией throttle’ит, но не портит состояние.

Связанные документы

Полный пример: examples/04-middlewarelog+ratelimit реально запущенные (см. также 10-mini-servicelog в сервисе побольше).

  • middleware.md — ядро Middleware/Router.@use, на котором это построено
  • auth.mdrequire_jwt/session, ещё два готовых промежуточных обработчика
  • src/middleware/ — полный исходник + pin-тесты для всех четырёх