atagen

malice

public
Star0
Watch0
0380cd8c765cdd7735d5c259c26abdeef712e742
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
crates/malice-static/src/lib.rsRust142 lines4.5 KB
1
pub mod idle;
2
3
use std::{future::Future, net::SocketAddr, time::Duration};
4
5
use axum::{
6
    Router,
7
    http::{HeaderName, HeaderValue, StatusCode, header},
8
    middleware,
9
    middleware::Next,
10
    response::{IntoResponse, Response},
11
    routing::any,
12
};
13
use listenfd::ListenFd;
14
use tower_http::{
15
    services::{ServeDir, ServeFile},
16
    trace::TraceLayer,
17
};
18
19
use crate::idle::IdleTracker;
20
21
fn bind_addr() -> SocketAddr {
22
    std::env::var("MALICE_BIND")
23
        .unwrap_or_else(|_| "127.0.0.1:3000".to_owned())
24
        .parse()
25
        .expect("MALICE_BIND must be host:port")
26
}
27
28
fn idle_exit_secs() -> Option<u64> {
29
    std::env::var("MALICE_IDLE_EXIT_SECS")
30
        .ok()
31
        .and_then(|value| value.trim().parse().ok())
32
}
33
34
/// Takes over a systemd-activated listener from fd 3 when present, so the
35
/// socket unit can hold the port before the process ever starts. Falls back
36
/// to a plain bind otherwise.
37
pub async fn bind_listener() -> tokio::net::TcpListener {
38
    let mut listenfd = ListenFd::from_env();
39
    if let Some(listener) = listenfd
40
        .take_tcp_listener(0)
41
        .expect("inspecting inherited sockets")
42
    {
43
        listener
44
            .set_nonblocking(true)
45
            .expect("setting inherited socket nonblocking");
46
        return tokio::net::TcpListener::from_std(listener)
47
            .expect("adopting inherited socket into tokio");
48
    }
49
    let addr = bind_addr();
50
    tracing::info!("listening on {addr}");
51
    tokio::net::TcpListener::bind(addr)
52
        .await
53
        .expect("binding the listener must succeed")
54
}
55
56
/// Serves `app` on `listener`, exiting on ctrl-c or, when
57
/// `MALICE_IDLE_EXIT_SECS` is set, after that long with no request in flight
58
/// or started. `on_shutdown` runs after the exit signal either way.
59
pub async fn serve(
60
    listener: tokio::net::TcpListener,
61
    app: Router,
62
    on_shutdown: impl Future<Output = ()> + Send + 'static,
63
) {
64
    let idle = idle_exit_secs().map(|secs| (IdleTracker::new(), secs));
65
    let app = match &idle {
66
        Some((tracker, _)) => {
67
            app.layer(middleware::from_fn_with_state(tracker.clone(), idle::track))
68
        }
69
        None => app,
70
    };
71
    axum::serve(listener, app)
72
        .with_graceful_shutdown(async move {
73
            match idle {
74
                Some((tracker, secs)) => {
75
                    tokio::select! {
76
                        _ = tokio::signal::ctrl_c() => {}
77
                        _ = tracker.wait_idle(Duration::from_secs(secs)) => {
78
                            tracing::info!("idle for {secs}s with no requests, exiting");
79
                        }
80
                    }
81
                }
82
                None => {
83
                    let _ = tokio::signal::ctrl_c().await;
84
                }
85
            }
86
            on_shutdown.await;
87
        })
88
        .await
89
        .expect("serving must succeed");
90
}
91
92
async fn api_not_found() -> StatusCode {
93
    StatusCode::NOT_FOUND
94
}
95
96
async fn missing_frontend() -> Response {
97
    (
98
        StatusCode::SERVICE_UNAVAILABLE,
99
        "frontend assets missing; set MALICE_WEB_DIR or use the Nix package",
100
    )
101
        .into_response()
102
}
103
104
fn is_no_cache_path(path: &str) -> bool {
105
    matches!(path, "/" | "/index.html" | "/sw.js")
106
}
107
108
/// index.html and sw.js must never be cached, or a stale service worker or
109
/// app shell survives a rebuild; the rest of the bundle is hashed and can be
110
/// cached freely.
111
async fn static_headers(request: axum::extract::Request, next: Next) -> Response {
112
    let no_cache = is_no_cache_path(request.uri().path());
113
    let mut response = next.run(request).await;
114
    if no_cache {
115
        let headers = response.headers_mut();
116
        headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
117
        headers.insert(
118
            HeaderName::from_static("service-worker-allowed"),
119
            HeaderValue::from_static("/"),
120
        );
121
    }
122
    response
123
}
124
125
/// Serves only the built frontend: no `/api` routes, no session state, no
126
/// data dir. Used for the socket-activated on-device build.
127
pub fn static_router(web_dir: Option<String>) -> Router {
128
    let router = Router::new()
129
        .route("/api", any(api_not_found))
130
        .route("/api/{*rest}", any(api_not_found))
131
        .layer(TraceLayer::new_for_http());
132
    match web_dir {
133
        Some(dir) => {
134
            let files =
135
                ServeDir::new(&dir).not_found_service(ServeFile::new(format!("{dir}/index.html")));
136
            router
137
                .fallback_service(files)
138
                .layer(middleware::from_fn(static_headers))
139
        }
140
        None => router.fallback(missing_frontend),
141
    }
142
}