• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

divviup / divviup-api / 25874010908

14 May 2026 05:09PM UTC coverage: 58.423% (+1.2%) from 57.254%
25874010908

push

github

web-flow
Migrate from Trillium [part 8]: remove Trillium server, serve Axum directly (#2251)

Axum is now the primary HTTP listener. The Trillium server, which was a pure pass-through proxy since Part 7C, is removed from the request path.

Production changes:
- `build_app(Config) -> BuiltApp` replaces `DivviupApi` for production use
- `bin.rs` rewritten: Axum serves directly, monitoring server is a separate Axum router, graceful shutdown via tokio signals + CancellationToken
- `Queue` uses `CancellationToken` instead of Trillium's `Stopper`/`CloneCounterObserver`
- `telemetry.rs` and `trace.rs` handlers converted from Trillium to Axum
- `Config` gains `listen_address` field (from HOST/PORT env vars, default [::]:8080)
- Replace the Trillium-based static asset handler (`trillium-static-compiled` + `OriginRouter`) with an Axum middleware using `tower-http`'s `ServeDir` and `ServeFile`.

The middleware intercepts requests whose `Host` (or `X-Forwarded-Host`) matches the configured `app_url` and serves the React SPA with appropriate `cache-control` headers (`max-age=1year` for `/assets/*`, `no-cache` for everything else). Unmatched hosts pass through to the API routes.

Dead code removal:
- Deleted: `handler/logger.rs`, `handler/opentelemetry.rs`, `axum_proxy` test
- Removed `FromConn` impls from: `Db`, `User`, `PermissionsActor`, `AccountBearerToken`
- Removed Trillium `Handler` impls from: `Db`, `ErrorHandler`, `CorsHeaders`, `ReplaceMimeTypes`, `SessionStore`
- Unified `PermissionsActor::is_allowed`/`if_allowed` to use `http::Method`
- Removed deps: `trillium-compression`, `trillium-conn-id`, `trillium-forwarding`, `trillium-opentelemetry`, `trillium-prometheus`, `trillium-redirect`, `trillium-sessions`, `trillium-cookies`, `async-session`
- Added deps: `cookie` (key-expansion feature, for session key derivation)

`DivviupApi` is kept as a thin test-only shim that spawns Axum on IPv6 Localhost and proxies via the existing `AxumProxy`, preserving test-support co... (continued)

112 of 227 new or added lines in 14 files covered. (49.34%)

8 existing lines in 4 files now uncovered.

4335 of 7420 relevant lines covered (58.42%)

63.45 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.63
/src/handler/assets.rs
1
use axum::{
2
    body::Body,
3
    extract::{Request, State},
4
    http::{header, HeaderMap, HeaderValue, Method, StatusCode},
5
    middleware::Next,
6
    response::{IntoResponse, Response},
7
};
8
use std::{
9
    convert::Infallible,
10
    future::Future,
11
    path::{Path, PathBuf},
12
    pin::Pin,
13
    task::{Context, Poll},
14
};
15
use tower::Service;
16
use tower_http::services::{ServeDir, ServeFile};
17
use url::Url;
18

19
#[derive(Clone, Debug)]
20
pub struct AssetConfig {
21
    pub api_url: Url,
22
    pub app_host: String,
23
    serve_dir: ServeDir<IndexFallback>,
24
}
25

26
impl AssetConfig {
27
    pub fn new(api_url: &Url, app_url: &Url) -> Self {
277✔
28
        // TODO(#2263): move ASSET_DIR from compile-time to runtime env var
29
        let asset_dir = PathBuf::from(env!("ASSET_DIR"));
277✔
30
        let fallback = IndexFallback::new(asset_dir.join("index.html"));
277✔
31
        let serve_dir = ServeDir::new(asset_dir).fallback(fallback);
277✔
32
        let host = app_url.host_str().expect("app_url must have a host");
277✔
33
        let app_host = match app_url.port() {
277✔
NEW
34
            Some(port) => format!("{host}:{port}"),
×
35
            None => host.to_owned(),
277✔
36
        };
37
        Self {
277✔
38
            api_url: api_url.clone(),
277✔
39
            app_host,
277✔
40
            serve_dir,
277✔
41
        }
277✔
42
    }
277✔
43
}
44

45
/// Fallback service for `ServeDir` that serves `index.html` for
46
/// non-asset paths (SPA routing) and returns 404 for `/assets/*` misses.
47
#[derive(Clone, Debug)]
48
struct IndexFallback {
49
    serve_index: ServeFile,
50
}
51

52
impl IndexFallback {
53
    fn new(index_path: impl AsRef<Path>) -> Self {
277✔
54
        Self {
277✔
55
            serve_index: ServeFile::new(index_path),
277✔
56
        }
277✔
57
    }
277✔
58
}
59

60
impl Service<Request<Body>> for IndexFallback {
61
    type Response = Response<Body>;
62
    type Error = Infallible;
63
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
64

NEW
65
    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
×
NEW
66
        Service::<Request<Body>>::poll_ready(&mut self.serve_index, cx)
×
NEW
67
            .map_err(|e: Infallible| match e {})
×
NEW
68
    }
×
69

70
    fn call(&mut self, req: Request<Body>) -> Self::Future {
3✔
71
        if req.uri().path().starts_with("/assets") {
3✔
72
            Box::pin(async { Ok(StatusCode::NOT_FOUND.into_response()) })
1✔
73
        } else {
74
            let future = self.serve_index.call(req);
2✔
75
            Box::pin(async { Ok(future.await.into_response()) })
2✔
76
        }
77
    }
3✔
78
}
79

80
/// Assumes we are behind a reverse proxy that strips client-supplied
81
/// `X-Forwarded-Host` before injecting its own.
82
fn request_host(headers: &HeaderMap) -> Option<&str> {
278✔
83
    headers
278✔
84
        .get("x-forwarded-host")
278✔
85
        .or_else(|| headers.get(header::HOST))
278✔
86
        .and_then(|v| v.to_str().ok())
278✔
87
}
278✔
88

89
/// Axum middleware that serves the React SPA for requests whose `Host`
90
/// (or `X-Forwarded-Host`) matches the configured app origin. Requests
91
/// to other hosts pass through to the API routes.
92
pub async fn serve_assets(
278✔
93
    State(config): State<AssetConfig>,
278✔
94
    request: Request,
278✔
95
    next: Next,
278✔
96
) -> Response {
278✔
97
    let host_matches =
278✔
98
        request_host(request.headers()).is_some_and(|h| h.eq_ignore_ascii_case(&config.app_host));
278✔
99

100
    if !host_matches {
278✔
101
        return next.run(request).await;
271✔
102
    }
7✔
103

104
    if request.uri().path() == "/api_url" {
7✔
105
        let headers = [
1✔
106
            (header::CONTENT_TYPE, "text/plain"),
1✔
107
            (header::CACHE_CONTROL, "no-cache"),
1✔
108
        ];
1✔
109
        return if request.method() == Method::HEAD {
1✔
NEW
110
            (headers, "").into_response()
×
111
        } else {
112
            (headers, config.api_url.to_string()).into_response()
1✔
113
        };
114
    }
6✔
115

116
    let is_asset_path = request.uri().path().starts_with("/assets");
6✔
117

118
    let mut response = config.serve_dir.clone().call(request).await.into_response();
6✔
119

120
    let cache_value = if response.status().is_success() && is_asset_path {
6✔
121
        HeaderValue::from_static("max-age=31536000")
1✔
122
    } else {
123
        HeaderValue::from_static("no-cache")
5✔
124
    };
125
    response
6✔
126
        .headers_mut()
6✔
127
        .insert(header::CACHE_CONTROL, cache_value);
6✔
128
    response
6✔
129
}
278✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc