arcature 2026.2.0

Arcature application framework: a high-level Application facade over the certified Arcature subsystems, with the low-level Axum/Tower escape hatch preserved.
Documentation
//! The request pipeline assembler — composes the lifecycle zones in the
//! correct order (engine spec §7).
//!
//! The assembler is the single place that knows how the pre-routing layers,
//! the Axum router, the post-routing layers, and the fallback fit together.
//! It produces a single `Service<Request<Body>, Response = Response<Body>,
//! Error = Infallible> + Clone + Send + 'static` — the exact bound
//! `axum::serve` requires.
//!
//! # Zone ordering (see [`super::zones`])
//!
//! ```text
//! PRE_ROUTING   DevProxyLayer (dev-proxy) → RequestIdLayer (observe) → ProxyLayer
//! ROUTING       Axum route selection                 (router.into_service)
//! POST_ROUTING  Maintenance → Inertia → MatchedRoute → HttpLayer
//! HANDLER       application handler
//! FALLBACK       404 pages                           (fallback_service)
//! ```
//!
//! The dev proxy (AP2.1-3) is the outermost pre-routing layer when the
//! `dev-proxy` feature is on; it is inactive unless an endpoint resolves.
//! The endpoint comes from the explicit `.dev_proxy_endpoint(...)` builder
//! value, or — when that is `None` — from `ARCATURE_VITE_IPC` (the `arc dev`
//! convention). The `RequestIdLayer` (observe) and `ProxyLayer` are always
//! applied for a uniform service type.
//!
//! # Why the types stay uniform
//!
//! `axum::Router::layer` / `route_layer` / `fallback_service` all return
//! `Router<S>` — the layers are *internalized* into the router's service, so
//! the router type does not change regardless of how many layers are applied.
//! This lets [`assemble_router`] return `Routes<()>` unconditionally.
//!
//! The pre-routing wrap is a `ProxyLayer` — *always* applied (even when no
//! proxy function is installed) so the composed-service type is uniform per
//! feature cfg. A `None` proxy is a zero-overhead pass-through (one `Option`
//! check, no function call). `RequestIdLayer` (observe) is the outermost
//! pre-routing layer; it is cfg-gated, producing two distinct concrete
//! service types — one per `observe` cfg — each returned via `impl Trait`.
//!
//! # Security
//!
//! The proxy service validates rewrite URIs, redirect locations, and header
//! values (CRLF injection defense) — see the `proxy::service` module. All
//! internal failures become redacted 500 responses. The assembler itself
//! performs no I/O and introduces no new attack surface.

use std::convert::Infallible;
use std::future::Future;
use std::pin::Pin;

use tower::Layer;

use crate::application::{Application, ProxyFn};
use crate::axum::body::Body;
use crate::axum::http::Response;
use crate::proxy::ProxyLayer;
use crate::routing::Routes;

#[cfg(feature = "observe")]
use crate::observe::{HttpLayer, RequestIdLayer};

/// The boxed future type shared by every layer in the pre-routing stack. Both
/// `ProxyService` and `RequestIdService` (observe) return this exact type, so
/// the `impl Trait` return of [`into_service`] can name it as the `Future`
/// associated type — which carries the `Send` bound `axum::serve` requires.
type BoxFuture = Pin<Box<dyn Future<Output = Result<Response<Body>, Infallible>> + Send>>;

#[cfg(feature = "inertia")]
use crate::inertia::InertiaLayer;

#[cfg(feature = "pages")]
use crate::pages::MaintenanceLayer;

/// Compose the post-routing router: the 404 fallback, observe route-layers,
/// and the global post-routing layers (Inertia, maintenance).
///
/// Returns the assembled `Routes<()>` alongside the extracted proxy handle —
/// the proxy is pre-routing and is applied by [`into_service`], not here.
/// The router type is `Routes<()>` regardless of which layers are applied
/// (axum internalizes layers into the router's service).
pub(crate) fn assemble_router(app: Application<()>) -> (Routes<()>, Option<ProxyFn>) {
    // Destructure to move each field out exactly once. cfg-gated fields are
    // only bound when their feature is on; the corresponding cfg-gated blocks
    // below are the sole consumers. Lifecycle config fields (database, cache,
    // storage, mail, jobs) are consumed by `startup`, not the pipeline —
    // ignore them here with `: _`.
    let Application {
        routes,
        proxy,
        bind_address: _,
        port: _,
        #[cfg(feature = "inertia")]
        inertia_config,
        #[cfg(feature = "inertia")]
            page_contracts: _,
        #[cfg(feature = "pages")]
        pages,
        #[cfg(feature = "pages")]
        maintenance_guard,
        #[cfg(feature = "db")]
            database: _,
        #[cfg(feature = "cache")]
            cache_config: _,
        #[cfg(feature = "storage")]
            storage_config: _,
        #[cfg(feature = "mail")]
            mail_config: _,
        #[cfg(feature = "jobs")]
            jobs_registry: _,
        #[cfg(feature = "jobs")]
            worker_config: _,
        #[cfg(feature = "dx")]
        error_mapping,
        #[cfg(feature = "dev-proxy")]
            dev_proxy_endpoint: _,
    } = app;

    // `mut` is only needed when at least one post-routing layer or the pages
    // fallback is applied; gate the binding so `--no-default-features` (and
    // any feature subset that applies nothing) stays warning-free.
    #[cfg(any(
        feature = "pages",
        feature = "inertia",
        feature = "observe",
        feature = "dx"
    ))]
    let mut router = routes;
    #[cfg(not(any(
        feature = "pages",
        feature = "inertia",
        feature = "observe",
        feature = "dx"
    )))]
    let router = routes;

    // ── HANDLER / FALLBACK ──────────────────────────────────────────────
    // The 404 fallback. Installed first (innermost) so the post-routing
    // global layers wrap it too (a maintenance-mode 404 is a 503, not a 404).
    // When `pages` is off, Axum's default 404 fallback is used.
    #[cfg(feature = "pages")]
    {
        let pages = pages.unwrap_or_default();
        router = router.fallback_service(pages.not_found_service());
    }

    // ── POST_ROUTING: observe route-layers (matched routes only) ─────────
    // `route_layer` runs only on matched routes, after `MatchedPath` is set
    // by axum's route matcher — the adapter reads it. Order matters:
    // `route_layer(A).route_layer(B)` produces `B(A(handler))`, so B runs
    // first. MatchedRoute (B, outermost) sets the extension; HttpLayer (A,
    // innermost) reads it. Both are route_layers, so they do NOT run on the
    // 404 fallback (no MatchedPath there — HttpLayer would record `route:
    // None`, which is the intended design; the request-id is still set
    // pre-routing).
    #[cfg(feature = "observe")]
    {
        router =
            router
                .route_layer(HttpLayer::new())
                .route_layer(crate::axum::middleware::from_fn(
                    crate::pipeline::matched_route::set_matched_route,
                ));
    }

    // ── POST_ROUTING: Error mapping (global) ─────────────────────────────
    // Applied via `layer` (wraps the whole router including fallback). Runs
    // inside maintenance and Inertia (those short-circuits are already
    // handled on the request path), outside observe route-layers and the
    // handler — so it sees the handler's response and can reformat it. When
    // `None`, no layer is applied (zero overhead).
    #[cfg(feature = "dx")]
    {
        if let Some(map) = error_mapping {
            router = router.layer(crate::pipeline::error_mapping::ErrorMappingLayer::new(
                Some(map),
            ));
        }
    }

    // ── POST_ROUTING: Inertia (global) ──────────────────────────────────
    // Applied via `layer` (wraps the whole router including fallback). Runs
    // after maintenance on the request path, before the route-layers.
    #[cfg(feature = "inertia")]
    {
        if let Some(config) = inertia_config {
            router = router.layer(InertiaLayer::new(config));
        }
    }

    // ── POST_ROUTING: Maintenance (global, outermost post-routing) ───────
    // Applied last so it is outermost (runs first on the request path).
    // Short-circuits to 503 before Inertia processes the response. A read
    // failure in the maintenance store is treated as inactive (the observe
    // crate's design: a corrupt state file never walls every user).
    #[cfg(feature = "pages")]
    {
        if let Some(guard) = maintenance_guard {
            router = router.layer(MaintenanceLayer::new(guard));
        }
    }

    (router, proxy)
}

/// Compose the full request pipeline into a single servable service.
///
/// Post-routing layers go on the router ([`assemble_router`]); pre-routing
/// layers wrap the resulting `router.into_service::<Body>()`. The returned
/// service satisfies `axum::serve`'s `Service<Request, Response = Response,
/// Error = Infallible> + Clone + Send + 'static` bound. Call
/// `.into_make_service()` (via `axum::ServiceExt`) on the result before
/// passing it to `axum::serve`.
///
/// The `ProxyLayer` is *always* applied — a `None` proxy is a zero-overhead
/// pass-through — so the concrete service type is uniform per feature cfg.
/// `RequestIdLayer` (observe) is cfg-gated as the outermost pre-routing layer
/// (after the dev proxy, when `dev-proxy` is on).
///
/// # Pre-routing order (outermost → innermost)
///
/// ```text
/// DevProxyLayer (dev-proxy)  — forward Vite requests to IPC (AP2.1-3)
///   └ RequestIdLayer (observe) — assign x-request-id
///       └ ProxyLayer — application pre-routing policy
///           └ RouterIntoService — Axum route selection
/// ```
///
/// The dev proxy is outermost so Vite requests (`/@vite/`, `/src/...`, HMR
/// WebSocket) are short-circuited to Vite over IPC before any application
/// layer runs — they never receive a request-id or hit the app proxy. The
/// dev proxy is inactive unless `ARCATURE_VITE_IPC` is set (production builds
/// that enable the feature pay one `Option` check per request).
pub(crate) fn into_service(
    app: Application<()>,
) -> impl tower::Service<
    crate::axum::extract::Request<Body>,
    Response = Response<Body>,
    Error = Infallible,
    // Both `ProxyService` and `RequestIdService` (observe) return this exact
    // boxed-future type, so naming it as the `Future` associated type carries
    // the `Send` bound that `axum::serve` requires (`S::Future: Send`).
    // `DevProxyService` (dev-proxy) returns the same type.
    Future = BoxFuture,
> + Clone
+ Send
+ 'static {
    // Extract the dev-proxy endpoint *before* `assemble_router` consumes
    // `app`. The field is cfg-gated; when the feature is off the clone is
    // gone too. The field defaults to `None` (the `arc dev` env convention);
    // an explicit `.dev_proxy_endpoint(Some(path))` on the builder overrides
    // the env var — the typed, resolved-configuration seam (AGENTS.md §21).
    // A clone (one `PathBuf` alloc) is negligible at startup and avoids
    // changing `assemble_router`'s return type or borrowing `app`.
    #[cfg(feature = "dev-proxy")]
    let dev_proxy_endpoint = app.dev_proxy_endpoint.clone();

    let (router, proxy) = assemble_router(app);

    // ── PRE_ROUTING: ProxyLayer wraps the router service ────────────────
    // Always applied for a uniform service type. The proxy function (if any)
    // runs before route selection — a genuine pre-routing contract (engine
    // spec §3/§4). This fixes the architecture mismatch where the proxy was
    // previously wired via `Router::layer` (post-routing).
    let router_service = router.into_service::<Body>();
    let with_proxy = ProxyLayer::new(proxy).layer(router_service);

    // ── PRE_ROUTING: RequestIdLayer (observe) ───────────────────────────
    // Wraps the proxy so every *application* response — including proxy
    // redirects and short-circuits — receives the `x-request-id` header
    // (engine spec §8). The id is also inserted into request extensions for
    // handlers and HttpLayer to read. Vite requests bypass this layer
    // (the dev proxy, when on, is outermost).
    #[cfg(feature = "observe")]
    let composed = RequestIdLayer::new().layer(with_proxy);
    #[cfg(not(feature = "observe"))]
    let composed = with_proxy;

    // ── PRE_ROUTING: DevProxyLayer outermost (dev-proxy, AP2.1-3) ────────
    // Forwards Vite dev-server requests (modules, `@vite/`, HMR WebSocket) to
    // Vite over IPC — one TCP listener, no Vite port. Inactive (pass-through)
    // when no endpoint resolves, so production builds that enable the feature
    // pay only one `Option` check per request. The endpoint is resolved once
    // here (startup, not per-request — AGENTS.md §21): the explicit
    // `.dev_proxy_endpoint(...)` builder value takes precedence; when it is
    // `None`, the `ARCATURE_VITE_IPC` env var (the `arc dev` convention) is
    // consulted.
    #[cfg(feature = "dev-proxy")]
    {
        let endpoint = dev_proxy_endpoint.or_else(crate::dev_proxy::config::endpoint_from_env);
        crate::dev_proxy::DevProxyLayer::new(endpoint).layer(composed)
    }
    #[cfg(not(feature = "dev-proxy"))]
    {
        composed
    }
}