Skip to main content

webserver_base/webserver/
server.rs

1//! The web server builder.
2
3use std::future::{Future, IntoFuture};
4use std::net::{IpAddr, SocketAddr};
5use std::path::PathBuf;
6use std::str::FromStr;
7
8use axum::extract::DefaultBodyLimit;
9use axum::http::StatusCode;
10use axum::response::{IntoResponse, Response};
11use axum::routing::get;
12use axum::{Router, serve};
13use tokio::net::TcpListener;
14use tower_http::LatencyUnit;
15use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
16use tracing::{Level, error, info, instrument, warn};
17
18use crate::env;
19use crate::environment::Environment;
20
21use super::error::WebServerError;
22use super::shutdown::{AppShutdown, DEFAULT_DRAIN_TIMEOUT, Shutdown};
23use super::state::{StateParts, WebServerState};
24
25/// The environment variable holding the bind host.
26pub const ENV_HOST: &str = "WSB_HOST";
27
28/// The environment variable holding the bind port.
29pub const ENV_PORT: &str = "WSB_PORT";
30
31/// The port used when [`ENV_PORT`] is unset.
32pub const DEFAULT_PORT: u16 = 8080;
33
34/// The request body ceiling used when none is set.
35///
36/// 256 KiB comfortably covers a form post or a JSON payload while still
37/// bounding abuse. A project that accepts uploads raises it explicitly.
38pub const DEFAULT_BODY_LIMIT: usize = 256 * 1024;
39
40/// The prefix every built-in endpoint is nested under.
41///
42/// Fixed rather than configurable: every route in every project is then
43/// predictable from the outside, and the derived analytics endpoint can be
44/// stated in the documentation without qualification.
45pub const API_PREFIX: &str = "/api/v1";
46
47/// An HTTP server.
48///
49/// Requires only an address, a port and an [`Environment`]. A sidecar that
50/// serves nothing but its health check is a valid server; a full site adds
51/// [`frontend`](WebServer::frontend). A single binary can run several of these
52/// and drain them from one [`Shutdown`].
53pub struct WebServer<S = ()> {
54    host: String,
55    port: u16,
56    environment: Environment,
57    app: S,
58
59    body_limit: usize,
60    root_dir: PathBuf,
61    router: Router<WebServerState<S>>,
62
63    #[cfg(feature = "pages")]
64    frontend: Option<super::frontend::FrontendParams<S>>,
65
66    /// Gated on `pages` rather than `feed`: a feed without a frontend is a
67    /// boot failure, so the two always travel together.
68    #[cfg(feature = "pages")]
69    feed: Option<crate::feed::Feed>,
70}
71
72impl WebServer<()> {
73    /// A server with no application state.
74    #[must_use]
75    pub fn new(host: impl Into<String>, port: u16, environment: Environment) -> Self {
76        Self::with_state(host, port, environment, ())
77    }
78
79    /// A server with no application state, read entirely from the environment.
80    ///
81    /// `WSB_ENVIRONMENT` is required. `WSB_HOST` defaults to `127.0.0.1`
82    /// locally and `0.0.0.0` in production; `WSB_PORT` defaults to
83    /// [`DEFAULT_PORT`].
84    ///
85    /// # Errors
86    ///
87    /// [`WebServerError::Env`] if a variable is missing or malformed.
88    pub fn from_env() -> Result<Self, WebServerError> {
89        Self::from_env_with_state(())
90    }
91}
92
93impl<S> WebServer<S>
94where
95    S: Clone + Send + Sync + 'static,
96{
97    /// A server carrying application state.
98    #[must_use]
99    pub fn with_state(
100        host: impl Into<String>,
101        port: u16,
102        environment: Environment,
103        app: S,
104    ) -> Self {
105        Self {
106            host: host.into(),
107            port,
108            environment,
109            app,
110            body_limit: DEFAULT_BODY_LIMIT,
111            // Empty joins to the bare relative path: the working directory.
112            root_dir: PathBuf::new(),
113            router: Router::new(),
114            #[cfg(feature = "pages")]
115            frontend: None,
116            #[cfg(feature = "pages")]
117            feed: None,
118        }
119    }
120
121    /// A server carrying application state, read entirely from the
122    /// environment.
123    ///
124    /// # Errors
125    ///
126    /// [`WebServerError::Env`] if a variable is missing or malformed.
127    pub fn from_env_with_state(app: S) -> Result<Self, WebServerError> {
128        let environment: Environment = Environment::from_env()?;
129        let host: String = env::optional(ENV_HOST).unwrap_or_else(|| {
130            if environment.is_production() {
131                String::from("0.0.0.0")
132            } else {
133                String::from("127.0.0.1")
134            }
135        });
136        let port: u16 =
137            env::parse_or(ENV_PORT, "port number", DEFAULT_PORT).map_err(WebServerError::Env)?;
138
139        Ok(Self::with_state(host, port, environment, app))
140    }
141
142    /// Caps request bodies. Defaults to [`DEFAULT_BODY_LIMIT`].
143    ///
144    /// The one size that genuinely varies: an image-upload endpoint and a
145    /// landing page have nothing in common here.
146    #[must_use]
147    pub const fn body_limit(mut self, body_limit: usize) -> Self {
148        self.body_limit = body_limit;
149        self
150    }
151
152    /// Where `html/`, `static/` and `cache-buster.json` are read from.
153    /// Defaults to the working directory.
154    ///
155    /// Covers every file the server reads, at boot and per request. It does
156    /// not move the build-time asset pipeline, which always runs in the working
157    /// directory. Set it when the process cannot run from the built site: a
158    /// `cargo test` runs from its crate's directory, not from `bin/`.
159    #[must_use]
160    pub fn root_dir(mut self, root_dir: impl Into<PathBuf>) -> Self {
161        self.root_dir = root_dir.into();
162        self
163    }
164
165    /// Nests a router under `path`.
166    #[must_use]
167    pub fn nest(mut self, path: &str, router: Router<WebServerState<S>>) -> Self {
168        self.router = self.router.nest(path, router);
169        self
170    }
171
172    /// Merges a router at the root.
173    #[must_use]
174    pub fn merge(mut self, router: Router<WebServerState<S>>) -> Self {
175        self.router = self.router.merge(router);
176        self
177    }
178
179    /// Nests a service under `path`.
180    #[must_use]
181    pub fn nest_service<T>(mut self, path: &str, service: T) -> Self
182    where
183        T: tower::Service<axum::extract::Request, Error = std::convert::Infallible>
184            + Clone
185            + Send
186            + Sync
187            + 'static,
188        T::Response: axum::response::IntoResponse,
189        T::Future: Send + 'static,
190    {
191        self.router = self.router.nest_service(path, service);
192        self
193    }
194
195    /// Declares this server a frontend: it serves HTML to humans.
196    ///
197    /// This is the line between a site and a service, and everything downstream
198    /// hangs off it — the embedded layout, the icon set, the web manifest,
199    /// `robots.txt`, the sitemaps, analytics and browser error monitoring. A
200    /// server that never calls it needs none of them and boots clean.
201    #[cfg(feature = "pages")]
202    #[must_use]
203    pub fn frontend(mut self, params: super::frontend::FrontendParams<S>) -> Self {
204        self.frontend = Some(params);
205        self
206    }
207
208    /// Gives this site a feed, served as RSS 2.0, Atom 1.0 and JSON Feed.
209    ///
210    /// Omittable, because most sites publish no stream: a site that never calls
211    /// this serves no feed documents and emits no autodiscovery links. Requires
212    /// [`frontend`](WebServer::frontend) — calling this without it fails the
213    /// boot rather than silently serving nothing.
214    ///
215    /// Entries are supplied rather than derived: a blog is a
216    /// [`dynamic_page_group`](Pages::dynamic_page_group), and a dynamic page
217    /// builds its template data per request, so there is nothing to read at
218    /// boot. They may be in any order and may exceed
219    /// [`MAX_FEED_ENTRIES`](crate::feed::MAX_FEED_ENTRIES); the newest survive.
220    #[cfg(feature = "pages")]
221    #[must_use]
222    pub fn feed(mut self, feed: crate::feed::Feed) -> Self {
223        self.feed = Some(feed);
224        self
225    }
226
227    /// The finished application, without binding a socket.
228    ///
229    /// Exactly the router [`run`](WebServer::run) serves — pages, the 404, the
230    /// well-known documents, the cache policy, tracing and the body limit — so
231    /// driving it with `tower::ServiceExt::oneshot` tests what ships. Boot
232    /// validation runs here too: `Ok` is a server `run` would have started.
233    ///
234    /// It never runs the drain window or [`AppShutdown::on_shutdown`];
235    /// triggering `shutdown` reaches only handlers that listen for it. A
236    /// `.layer()` added on top of the result is outside what this library
237    /// guarantees.
238    ///
239    /// # Errors
240    ///
241    /// [`WebServerError`] if the frontend cannot be assembled.
242    pub fn into_router(self, shutdown: Shutdown) -> Result<Router, WebServerError> {
243        Ok(self.assemble(shutdown)?.router)
244    }
245
246    /// Binds, serves, and drains.
247    ///
248    /// # Errors
249    ///
250    /// [`WebServerError`] if the host is unparseable, the port cannot be bound,
251    /// the frontend cannot be assembled, or serving fails.
252    #[instrument(skip_all)]
253    pub async fn run(self, shutdown: Shutdown) -> Result<(), WebServerError>
254    where
255        S: AppShutdown,
256    {
257        let Assembled {
258            router,
259            state,
260            host,
261            port,
262        } = self.assemble(shutdown.clone())?;
263
264        serve_on(router, &host, port, shutdown, async move {
265            state.app().on_shutdown().await;
266        })
267        .await
268    }
269
270    /// Everything [`run`](WebServer::run) does before it binds.
271    ///
272    /// The one assembly path, so the router [`into_router`](WebServer::into_router)
273    /// hands a test cannot drift from the one production serves.
274    #[instrument(skip_all)]
275    fn assemble(self, shutdown: Shutdown) -> Result<Assembled<S>, WebServerError> {
276        let Self {
277            host,
278            port,
279            environment,
280            app,
281            body_limit,
282            root_dir,
283            router,
284            #[cfg(feature = "pages")]
285            frontend,
286            #[cfg(feature = "pages")]
287            feed,
288        } = self;
289
290        // A feed on a server that is not a frontend is never served, and
291        // nothing at runtime would say so.
292        #[cfg(feature = "pages")]
293        if feed.is_some() && frontend.is_none() {
294            return Err(WebServerError::FeedWithoutFrontend);
295        }
296
297        // Hashing is a build step, so this only ever reads what the build
298        // produced. A project with no `static/` gets an empty map.
299        let cache_buster: crate::assets::CacheBuster =
300            crate::assets::CacheBuster::load_in(&root_dir)?;
301
302        let mut no_cache: Router<WebServerState<S>> = router;
303        let mut built_in: Router<WebServerState<S>> = Router::new().route("/health", get(health));
304
305        #[cfg(feature = "pages")]
306        let mut frontend_runtime: Option<crate::templates::FrontendRuntime> = None;
307        #[cfg(feature = "pages")]
308        let mut base: Option<crate::templates::BaseTemplateData> = None;
309        #[cfg(feature = "pages")]
310        let mut templates: Option<crate::templates::TemplateRegistry<'static>> = None;
311        #[cfg(feature = "pages")]
312        let mut not_found: Option<(
313            std::sync::Arc<crate::templates::PageTemplateData>,
314            std::sync::Arc<serde_json::Value>,
315        )> = None;
316        #[cfg(feature = "pages")]
317        let mut proxy_scripts: Option<Router<WebServerState<S>>> = None;
318        #[cfg(feature = "pages")]
319        let mut feed_router: Option<Router<WebServerState<S>>> = None;
320
321        log_immutable_assets(&cache_buster);
322
323        #[cfg(feature = "pages")]
324        if let Some(params) = frontend {
325            let assembled: AssembledFrontend<S> =
326                assemble_frontend(params, feed, &cache_buster, environment)?;
327
328            no_cache = no_cache.merge(assembled.routes);
329            built_in = built_in.merge(assembled.api);
330            proxy_scripts = Some(assembled.proxy_scripts);
331            feed_router = assembled.feeds;
332            not_found = Some(assembled.not_found);
333            frontend_runtime = Some(assembled.runtime);
334            base = Some(assembled.base);
335            templates = Some(assembled.templates);
336        }
337
338        let no_cache: Router<WebServerState<S>> = no_cache.nest(API_PREFIX, built_in);
339
340        let mut app_router: Router<WebServerState<S>> = apply_cache_policy(no_cache, &cache_buster);
341
342        // Merged after the never-cache layer so the upstream's own headers
343        // survive: the vendor scripts carry their own policy, and stamping
344        // `no-store` over it would re-download them on every page view.
345        #[cfg(feature = "pages")]
346        if let Some(scripts) = proxy_scripts {
347            app_router = app_router.merge(scripts);
348        }
349
350        #[cfg(feature = "pages")]
351        if let Some(feeds) = feed_router {
352            app_router = app_router.merge(feeds);
353        }
354
355        #[cfg(feature = "pages")]
356        let app_router: Router<WebServerState<S>> = attach_not_found(app_router, not_found);
357        #[cfg(not(feature = "pages"))]
358        let app_router: Router<WebServerState<S>> = app_router.fallback(plain_not_found);
359
360        let state: WebServerState<S> = WebServerState::new(StateParts {
361            host: host.clone(),
362            port,
363            environment,
364            shutdown,
365            #[cfg(feature = "templates")]
366            base,
367            #[cfg(feature = "templates")]
368            templates,
369            cache_buster: Some(cache_buster),
370            #[cfg(feature = "templates")]
371            frontend: frontend_runtime,
372            app,
373        });
374
375        // Applied outermost-last, so the body cap runs before tracing sees a
376        // request it may never finish reading.
377        // The router takes the state by value; `run`'s cleanup needs it after
378        // serving ends, and `WebServerState` is an `Arc` so this is a refcount.
379        let app_router: Router = app_router
380            .with_state(state.clone())
381            .layer(
382                TraceLayer::new_for_http()
383                    // The span carries the method and URI, and the response
384                    // event is logged inside it. Left at its default DEBUG
385                    // level the span is never created under an INFO filter, so
386                    // every request logs a status and a latency with no way to
387                    // tell which route it was.
388                    .make_span_with(
389                        DefaultMakeSpan::new()
390                            .level(Level::INFO)
391                            .include_headers(false),
392                    )
393                    .on_response(
394                        DefaultOnResponse::new()
395                            .level(Level::INFO)
396                            .latency_unit(LatencyUnit::Millis),
397                    ),
398            )
399            .layer(DefaultBodyLimit::max(body_limit));
400
401        Ok(Assembled {
402            router: app_router,
403            state,
404            host,
405            port,
406        })
407    }
408}
409
410/// A server ready to bind: what [`WebServer::run`] serves and what it cleans
411/// up afterwards.
412struct Assembled<S> {
413    router: Router,
414    /// Kept beside the router because `with_state` consumes it, and the
415    /// shutdown hook runs after serving ends.
416    state: WebServerState<S>,
417    host: String,
418    port: u16,
419}
420
421/// Attaches the 404 fallback.
422///
423/// A frontend renders its own page at the requested URL with a real 404 status;
424/// a service with no pages answers with a bare body. Split out of `run` because
425/// it is the one branch there that is about a single route rather than about
426/// assembling the router.
427#[cfg(feature = "pages")]
428fn attach_not_found<S>(
429    router: Router<WebServerState<S>>,
430    not_found: Option<(
431        std::sync::Arc<crate::templates::PageTemplateData>,
432        std::sync::Arc<serde_json::Value>,
433    )>,
434) -> Router<WebServerState<S>>
435where
436    S: Clone + Send + Sync + 'static,
437{
438    match not_found {
439        Some((page, data)) => router.fallback(move |axum::extract::State(state)| {
440            let page: std::sync::Arc<crate::templates::PageTemplateData> =
441                std::sync::Arc::clone(&page);
442            let data: std::sync::Arc<serde_json::Value> = std::sync::Arc::clone(&data);
443            async move {
444                let body: Response = super::pages::render_or_500(&state, &page, &data);
445                (StatusCode::NOT_FOUND, body).into_response()
446            }
447        }),
448        None => router.fallback(plain_not_found),
449    }
450}
451
452/// Binds and serves until the last connection closes, or the drain window
453/// shuts, whichever comes first.
454///
455/// `on_shutdown` is the application's own cleanup. It is only ever awaited if a
456/// shutdown signal actually arrives — a server that dies on a bind error drops
457/// it unrun, rather than hanging on cleanup nobody asked for.
458async fn serve_on<F>(
459    router: Router,
460    host: &str,
461    port: u16,
462    shutdown: Shutdown,
463    on_shutdown: F,
464) -> Result<(), WebServerError>
465where
466    F: Future<Output = ()> + Send,
467{
468    let ip: IpAddr = IpAddr::from_str(host).map_err(|source| WebServerError::Bind {
469        addr: SocketAddr::from(([0, 0, 0, 0], port)),
470        source: std::io::Error::new(std::io::ErrorKind::InvalidInput, source),
471    })?;
472    let address: SocketAddr = SocketAddr::new(ip, port);
473    let listener: TcpListener =
474        TcpListener::bind(address)
475            .await
476            .map_err(|source| WebServerError::Bind {
477                addr: address,
478                source,
479            })?;
480
481    info!("listening on http://{address}");
482
483    // The one binding in the crate with no written type: `WithGracefulShutdown`
484    // is generic over the listener, the make-service, the service and the
485    // shutdown future, and rustc itself elides it when printing.
486    let serving = serve(
487        listener,
488        router.into_make_service_with_connect_info::<SocketAddr>(),
489    )
490    .with_graceful_shutdown(shutdown.clone().recv())
491    .into_future();
492    tokio::pin!(serving);
493
494    // The drain window is a ceiling on the wait, not the wait itself, so the
495    // clock cannot start until the drain does — and `serving` resolves the
496    // moment the last connection closes, which for an idle server is at once.
497    tokio::select! {
498        result = &mut serving => return result.map_err(WebServerError::Serve),
499        () = shutdown.recv() => {}
500    }
501
502    // Two ceilings started at one instant, not one after the other: the drain
503    // and the cleanup are independent, so the process leaves in the longer of
504    // the two rather than their sum — which is what keeps the whole shutdown
505    // inside a single orchestrator kill window.
506    let (served, cleaned) = tokio::join!(
507        tokio::time::timeout(DEFAULT_DRAIN_TIMEOUT, &mut serving),
508        tokio::time::timeout(DEFAULT_DRAIN_TIMEOUT, on_shutdown),
509    );
510
511    // The hook's own reporting never runs when it is cancelled from out here,
512    // so the overrun has to be reported from out here too, or the work it
513    // failed to finish is lost silently.
514    if cleaned.is_err() {
515        error!(
516            "app shutdown hook exceeded {DEFAULT_DRAIN_TIMEOUT:?}; cleanup was cancelled part-way"
517        );
518    }
519
520    match served {
521        Ok(result) => result.map_err(WebServerError::Serve),
522        // Expected of anything holding a socket open, not a misconfiguration:
523        // the ceiling exists precisely because such a connection never ends.
524        Err(_elapsed) => {
525            warn!("drain window elapsed after {DEFAULT_DRAIN_TIMEOUT:?}; closing what remained");
526            Ok(())
527        }
528    }
529}
530
531/// The two cache policies: nothing outside `/static` may be cached, and
532/// everything inside it is immutable because its URL carries a content hash.
533fn apply_cache_policy<S>(
534    router: Router<WebServerState<S>>,
535    cache_buster: &crate::assets::CacheBuster,
536) -> Router<WebServerState<S>>
537where
538    S: Clone + Send + Sync + 'static,
539{
540    let router: Router<WebServerState<S>> = router.layer(axum::middleware::from_fn(
541        crate::assets::CacheBuster::never_cache_middleware,
542    ));
543
544    if cache_buster.is_empty() {
545        return router;
546    }
547
548    router.merge(
549        Router::new()
550            .nest_service(
551                "/static",
552                tower_http::services::ServeDir::new(
553                    cache_buster.root().join(crate::assets::STATIC_DIRECTORY),
554                ),
555            )
556            .layer(axum::middleware::from_fn(
557                crate::assets::CacheBuster::forever_cache_middleware,
558            )),
559    )
560}
561
562/// `GET /api/v1/health`. Always routed: every deployment target expects one,
563/// and a bool to switch it off was surface for nothing.
564async fn health() -> StatusCode {
565    StatusCode::OK
566}
567
568/// The fallback for a server with no 404 page of its own.
569async fn plain_not_found() -> Response {
570    (StatusCode::NOT_FOUND, "404").into_response()
571}
572
573/// Serves the generated and embedded documents from memory.
574///
575/// None of these is a file. Sitemaps need the route table, so they are built at
576/// boot; `robots.txt` and the manifest are derived from site data; humans.txt is
577/// compiled in. All are served at fixed never-cached routes where a content hash
578/// would mean nothing — which is also why the server needs no writable disk.
579#[cfg(feature = "pages")]
580fn well_known_routes<S>(well_known: &super::frontend::WellKnown) -> Router<WebServerState<S>>
581where
582    S: Clone + Send + Sync + 'static,
583{
584    use axum::http::header;
585
586    fn text<S>(
587        router: Router<WebServerState<S>>,
588        path: &str,
589        content_type: &'static str,
590        body: String,
591    ) -> Router<WebServerState<S>>
592    where
593        S: Clone + Send + Sync + 'static,
594    {
595        router.route(
596            path,
597            get(move || {
598                let body: String = body.clone();
599                async move { ([(header::CONTENT_TYPE, content_type)], body) }
600            }),
601        )
602    }
603
604    let mut router: Router<WebServerState<S>> = Router::new();
605    router = text(
606        router,
607        "/robots.txt",
608        "text/plain; charset=utf-8",
609        well_known.robots_txt.clone(),
610    );
611    router = text(
612        router,
613        "/humans.txt",
614        "text/plain; charset=utf-8",
615        well_known.humans_txt.clone(),
616    );
617    router = text(
618        router,
619        "/site.webmanifest",
620        "application/manifest+json",
621        well_known.webmanifest.clone(),
622    );
623    router = text(
624        router,
625        crate::sitemap::SITEMAP_INDEX_PATH,
626        "application/xml",
627        well_known.sitemaps.index().to_string(),
628    );
629    for (index, chunk) in well_known.sitemaps.chunks().iter().enumerate() {
630        router = text(
631            router,
632            &format!("/sitemap-{}.xml", index + 1),
633            "application/xml",
634            chunk.clone(),
635        );
636    }
637    router
638}
639
640/// Serves the icon set at the well-known root paths browsers actually request.
641///
642/// Each one resolves through the manifest to its hashed file under `/static`,
643/// so the same bytes are reachable both ways: immutable at the hashed URL, and
644/// never-cached here where the URL cannot change.
645#[cfg(feature = "pages")]
646fn icon_routes<S>(
647    cache_buster: &crate::assets::CacheBuster,
648    has_svg_icon: bool,
649) -> Router<WebServerState<S>>
650where
651    S: Clone + Send + Sync + 'static,
652{
653    use tower_http::services::ServeFile;
654
655    let mut icons: Vec<(&str, String)> = vec![
656        (
657            "/favicon.ico",
658            String::from("static/image/favicon/favicon.ico"),
659        ),
660        (
661            "/apple-touch-icon.png",
662            String::from("static/image/favicon/apple-touch-icon.png"),
663        ),
664        (
665            "/icon-192.png",
666            String::from("static/image/favicon/icon-192.png"),
667        ),
668        (
669            "/icon-512.png",
670            String::from("static/image/favicon/icon-512.png"),
671        ),
672    ];
673    // Only a project whose art is vector has one to serve.
674    if has_svg_icon {
675        icons.push((
676            "/favicon.svg",
677            String::from("static/image/favicon/favicon.svg"),
678        ));
679    }
680
681    let mut router: Router<WebServerState<S>> = Router::new();
682    for (route, original) in icons {
683        // Existence was proved at boot, so this is a resolution, not a check.
684        router = router.nest_service(route, ServeFile::new(cache_buster.file(&original)));
685    }
686    router
687}
688
689/// The feed routes, with the conditional-GET handling the rest of the site has
690/// no use for.
691///
692/// A feed is polled relentlessly — a few hundred subscribers across Feedly,
693/// Inoreader and `NetNewsWire` is tens of thousands of requests a day for a
694/// document that changes twice a year. A strong validator computed at boot
695/// turns all but the first of those into a bodiless `304`.
696///
697/// These routes never see [`CacheBuster::never_cache_middleware`], which
698/// removes `ETag` and `If-None-Match` outright.
699#[cfg(feature = "pages")]
700fn feed_routes<S>(feeds: &crate::feed::FeedSet) -> Router<WebServerState<S>>
701where
702    S: Clone + Send + Sync + 'static,
703{
704    use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
705
706    // Parsed once, at boot: a header value that cannot be built is a
707    // programming error, and discovering it per request would mean either a
708    // panic in a handler or a feed served without its validators.
709    let last_modified: HeaderValue = HeaderValue::from_str(
710        &feeds
711            .last_modified()
712            .format("%a, %d %b %Y %H:%M:%S GMT")
713            .to_string(),
714    )
715    .unwrap_or_else(|_| HeaderValue::from_static(""));
716    let cache_control: HeaderValue = HeaderValue::from_str(&format!(
717        "public, max-age={}",
718        crate::feed::FEED_MAX_AGE_SECONDS
719    ))
720    .unwrap_or_else(|_| HeaderValue::from_static("public"));
721
722    let mut router: Router<WebServerState<S>> = Router::new();
723    for document in feeds.documents() {
724        let body: String = String::from(document.body());
725        let etag_text: String = String::from(document.etag());
726        let etag: HeaderValue =
727            HeaderValue::from_str(&etag_text).unwrap_or_else(|_| HeaderValue::from_static(""));
728        let content_type: HeaderValue = HeaderValue::from_static(document.content_type());
729        let last_modified: HeaderValue = last_modified.clone();
730        let cache_control: HeaderValue = cache_control.clone();
731
732        router = router.route(
733            document.path(),
734            get(move |headers: HeaderMap| {
735                let body: String = body.clone();
736                let etag_text: String = etag_text.clone();
737                let etag: HeaderValue = etag.clone();
738                let content_type: HeaderValue = content_type.clone();
739                let last_modified: HeaderValue = last_modified.clone();
740                let cache_control: HeaderValue = cache_control.clone();
741                async move {
742                    let fresh: bool = if_none_match(&headers, &etag_text);
743
744                    // Built rather than tupled so the headers are *inserted*:
745                    // appending leaves axum's own `text/plain` for the string
746                    // body in place ahead of ours, and a reader that reads the
747                    // first `Content-Type` then treats the feed as plain text.
748                    let mut response: Response = if fresh {
749                        StatusCode::NOT_MODIFIED.into_response()
750                    } else {
751                        (StatusCode::OK, body).into_response()
752                    };
753
754                    let headers: &mut HeaderMap = response.headers_mut();
755                    headers.insert(header::CACHE_CONTROL, cache_control);
756                    headers.insert(header::ETAG, etag);
757                    headers.insert(header::LAST_MODIFIED, last_modified);
758                    if !fresh {
759                        headers.insert(header::CONTENT_TYPE, content_type);
760                    }
761
762                    response
763                }
764            }),
765        );
766    }
767
768    router
769}
770
771/// Whether the request already holds this exact document.
772///
773/// `If-None-Match` is a comma-separated list, entries may be weak (`W/"…"`),
774/// and `*` matches anything that exists — so a bare string comparison against
775/// the header would 200 on requests that should 304.
776#[cfg(feature = "pages")]
777fn if_none_match(headers: &axum::http::HeaderMap, etag: &str) -> bool {
778    let Some(header) = headers
779        .get(axum::http::header::IF_NONE_MATCH)
780        .and_then(|value| value.to_str().ok())
781    else {
782        return false;
783    };
784
785    header.split(',').any(|candidate| {
786        let candidate: &str = candidate.trim();
787        candidate == "*" || candidate.trim_start_matches("W/") == etag
788    })
789}
790
791/// Names every immutable asset and the URL it is actually served at, once per
792/// boot.
793///
794/// Per-request logging cannot answer the question this exists for: a request
795/// for an immutable asset reaching the origin is indistinguishable from a first
796/// visit, a bot, a hard refresh or an evicted entry, and only one of those is a
797/// fault. What *is* checkable is whether the tier was wired up at all, and that
798/// is decided once, here. The hashed path is printed because it is the URL to
799/// `curl` when verifying the edge.
800fn log_immutable_assets(cache_buster: &crate::assets::CacheBuster) {
801    for (original, hashed) in cache_buster.cache() {
802        info!("immutable 1y  /{original} -> /{hashed}");
803    }
804}
805
806/// Names the generated documents and the tier each is served under.
807#[cfg(feature = "pages")]
808fn log_served_documents(well_known: &super::frontend::WellKnown) {
809    for path in ["/robots.txt", "/humans.txt", "/site.webmanifest"] {
810        info!("uncached      {path}");
811    }
812    for path in well_known.sitemaps.paths() {
813        info!("uncached      {path}");
814    }
815
816    if let Some(feeds) = well_known.feeds.as_ref() {
817        for document in feeds.documents() {
818            info!(
819                "cached {}m    {} (etag {})",
820                crate::feed::FEED_MAX_AGE_SECONDS / 60,
821                document.path(),
822                document.etag()
823            );
824        }
825    }
826}
827
828/// Everything a frontend contributes to the router, assembled.
829///
830/// A struct rather than a tuple of eight: `run` merges these into differently
831/// cached layers, and two routers transposed would put the analytics script
832/// behind `no-store`.
833#[cfg(feature = "pages")]
834struct AssembledFrontend<S> {
835    /// Pages, icons and the never-cached well-known documents.
836    routes: Router<WebServerState<S>>,
837    /// Endpoints to nest under the API prefix.
838    api: Router<WebServerState<S>>,
839    /// Vendor scripts, which carry their own cache policy.
840    proxy_scripts: Router<WebServerState<S>>,
841    /// Feed documents, which carry theirs.
842    feeds: Option<Router<WebServerState<S>>>,
843    runtime: crate::templates::FrontendRuntime,
844    base: crate::templates::BaseTemplateData,
845    templates: crate::templates::TemplateRegistry<'static>,
846    not_found: (
847        std::sync::Arc<crate::templates::PageTemplateData>,
848        std::sync::Arc<serde_json::Value>,
849    ),
850}
851
852/// Builds the frontend and sorts its routes by the cache policy each needs.
853#[cfg(feature = "pages")]
854fn assemble_frontend<S>(
855    params: super::frontend::FrontendParams<S>,
856    feed: Option<crate::feed::Feed>,
857    cache_buster: &crate::assets::CacheBuster,
858    environment: Environment,
859) -> Result<AssembledFrontend<S>, WebServerError>
860where
861    S: Clone + Send + Sync + 'static,
862{
863    let templates: crate::templates::TemplateRegistry<'static> =
864        crate::templates::TemplateRegistry::from_dir(
865            cache_buster.root().join(crate::templates::TEMPLATE_ROOT),
866        )?;
867
868    let built: super::frontend::Frontend<S> =
869        super::frontend::Frontend::build(params, feed, cache_buster, environment)?;
870
871    log_served_documents(&built.well_known);
872
873    // Feeds are deliberately kept out of the never-cache layer: it strips
874    // conditional headers and stamps `no-store`, which on the most-polled
875    // document a site serves means re-sending every byte on every poll.
876    let feeds: Option<Router<WebServerState<S>>> = built.well_known.feeds.as_ref().map(feed_routes);
877
878    let mut routes: Router<WebServerState<S>> = well_known_routes(&built.well_known);
879    routes = routes.merge(icon_routes(cache_buster, built.has_svg_icon));
880
881    // The vendor scripts are likewise kept out: they carry the vendor's own
882    // cache policy, and stamping `no-store` over it would re-download the
883    // analytics script on every page view.
884    let (proxy_scripts, api) = proxy_routes(&built);
885
886    let not_found = built.not_found.clone();
887    routes = routes.merge(built.pages.into_router());
888
889    Ok(AssembledFrontend {
890        routes,
891        api,
892        proxy_scripts,
893        feeds,
894        runtime: built.runtime,
895        base: built.base,
896        templates,
897        not_found,
898    })
899}
900
901/// The first-party proxy routes.
902///
903/// Split in two because the scripts sit at the root — where they read as
904/// ordinary bundler output — while the endpoints they post to belong under the
905/// API prefix.
906#[cfg(feature = "pages")]
907fn proxy_routes<S>(
908    frontend: &super::frontend::Frontend<S>,
909) -> (Router<WebServerState<S>>, Router<WebServerState<S>>)
910where
911    S: Clone + Send + Sync + 'static,
912{
913    use crate::analytics::{AnalyticsConfig, relay_envelope, relay_event, relay_script};
914    use axum::body::Bytes;
915    use axum::extract::ConnectInfo;
916    use axum::http::HeaderMap;
917    use axum::routing::post;
918
919    let client: reqwest::Client = reqwest::Client::new();
920    let paths: &crate::templates::FrontendRuntime = &frontend.runtime;
921
922    let analytics_script_upstream: String = frontend.analytics.upstream_script_url();
923    let analytics_event_upstream: String = AnalyticsConfig::upstream_event_url();
924    let sentry_script_upstream: String = frontend.sentry_dsn.upstream_script_url();
925    let sentry_envelope_upstream: String = frontend.sentry_dsn.upstream_envelope_url();
926
927    let scripts: Router<WebServerState<S>> = Router::new()
928        .route(
929            &paths.analytics.script_path,
930            get({
931                let client: reqwest::Client = client.clone();
932                let upstream: std::sync::Arc<str> =
933                    std::sync::Arc::from(analytics_script_upstream.as_str());
934                move || {
935                    let client: reqwest::Client = client.clone();
936                    let upstream: std::sync::Arc<str> = std::sync::Arc::clone(&upstream);
937                    async move { relay_script(&client, &upstream).await }
938                }
939            }),
940        )
941        .route(
942            &paths.sentry_browser.script_path,
943            get({
944                let client: reqwest::Client = client.clone();
945                let upstream: std::sync::Arc<str> =
946                    std::sync::Arc::from(sentry_script_upstream.as_str());
947                move || {
948                    let client: reqwest::Client = client.clone();
949                    let upstream: std::sync::Arc<str> = std::sync::Arc::clone(&upstream);
950                    async move { relay_script(&client, &upstream).await }
951                }
952            }),
953        );
954
955    // Nested under the API prefix, so the paths registered here are relative.
956    let event_path: String = strip_api_prefix(&paths.analytics.event_path);
957    let tunnel_path: String = strip_api_prefix(&paths.sentry_browser.tunnel_path);
958
959    let endpoints: Router<WebServerState<S>> = Router::new()
960        .route(
961            &event_path,
962            post({
963                let client: reqwest::Client = client.clone();
964                let upstream: std::sync::Arc<str> =
965                    std::sync::Arc::from(analytics_event_upstream.as_str());
966                move |ConnectInfo(peer): ConnectInfo<SocketAddr>,
967                      headers: HeaderMap,
968                      body: Bytes| {
969                    let client: reqwest::Client = client.clone();
970                    let upstream: std::sync::Arc<str> = std::sync::Arc::clone(&upstream);
971                    async move { relay_event(&client, &upstream, &headers, peer, body).await }
972                }
973            }),
974        )
975        .route(
976            &tunnel_path,
977            post({
978                let upstream: std::sync::Arc<str> =
979                    std::sync::Arc::from(sentry_envelope_upstream.as_str());
980                move |body: Bytes| {
981                    let client: reqwest::Client = client.clone();
982                    let upstream: std::sync::Arc<str> = std::sync::Arc::clone(&upstream);
983                    async move { relay_envelope(&client, &upstream, body).await }
984                }
985            }),
986        );
987
988    (scripts, endpoints)
989}
990
991/// `/api/v1/thing` → `/thing`, for a router that will be nested under the
992/// prefix.
993#[cfg(feature = "pages")]
994fn strip_api_prefix(path: &str) -> String {
995    path.strip_prefix(API_PREFIX)
996        .map_or_else(|| path.to_string(), String::from)
997}
998
999#[cfg(test)]
1000mod tests {
1001
1002    #[cfg(feature = "pages")]
1003    mod conditional_get {
1004        use axum::http::{HeaderMap, HeaderValue, header};
1005
1006        use crate::webserver::server::if_none_match;
1007
1008        const ETAG: &str = "\"abc123\"";
1009
1010        fn headers(value: &str) -> HeaderMap {
1011            let mut headers: HeaderMap = HeaderMap::new();
1012            headers.insert(
1013                header::IF_NONE_MATCH,
1014                HeaderValue::from_str(value).expect("a valid header"),
1015            );
1016            headers
1017        }
1018
1019        #[test]
1020        fn a_request_without_the_header_always_gets_the_body() {
1021            let expected: bool = false;
1022            let actual: bool = if_none_match(&HeaderMap::new(), ETAG);
1023            assert_eq!(expected, actual);
1024        }
1025
1026        #[test]
1027        fn the_same_validator_means_the_reader_already_has_this_feed() {
1028            let expected: bool = true;
1029            let actual: bool = if_none_match(&headers(ETAG), ETAG);
1030            assert_eq!(expected, actual);
1031        }
1032
1033        #[test]
1034        fn a_weak_validator_still_matches_because_feeds_need_no_byte_equality() {
1035            let expected: bool = true;
1036            let actual: bool = if_none_match(&headers("W/\"abc123\""), ETAG);
1037            assert_eq!(expected, actual);
1038        }
1039
1040        #[test]
1041        fn a_star_matches_anything_that_exists() {
1042            let expected: bool = true;
1043            let actual: bool = if_none_match(&headers("*"), ETAG);
1044            assert_eq!(expected, actual);
1045        }
1046
1047        #[test]
1048        fn one_match_anywhere_in_the_list_is_enough() {
1049            // The header is a comma-separated list, so a naive string compare
1050            // against the whole value would re-send the body to a reader that
1051            // already holds it.
1052            let expected: bool = true;
1053            let actual: bool = if_none_match(&headers("\"other\", \"abc123\""), ETAG);
1054            assert_eq!(expected, actual);
1055        }
1056
1057        #[test]
1058        fn a_stale_validator_gets_the_new_document() {
1059            let expected: bool = false;
1060            let actual: bool = if_none_match(&headers("\"stale\""), ETAG);
1061            assert_eq!(expected, actual);
1062        }
1063    }
1064    use std::sync::Arc;
1065    use std::sync::atomic::{AtomicBool, Ordering};
1066    use std::time::Duration;
1067
1068    use axum::Router;
1069    use axum::routing::get;
1070    use tokio::time::timeout;
1071
1072    use super::super::shutdown::Shutdown;
1073    use super::{
1074        API_PREFIX, AppShutdown, DEFAULT_BODY_LIMIT, DEFAULT_DRAIN_TIMEOUT, WebServerError, health,
1075        serve_on,
1076    };
1077
1078    /// Records whether cleanup ran, and can be made slow enough to overrun the
1079    /// drain window.
1080    #[derive(Clone)]
1081    struct RecordingState {
1082        ran: Arc<AtomicBool>,
1083        linger: Option<Duration>,
1084    }
1085
1086    impl RecordingState {
1087        fn instant() -> Self {
1088            Self {
1089                ran: Arc::new(AtomicBool::new(false)),
1090                linger: None,
1091            }
1092        }
1093    }
1094
1095    impl AppShutdown for RecordingState {
1096        async fn on_shutdown(&self) {
1097            if let Some(linger) = self.linger {
1098                tokio::time::sleep(linger).await;
1099            }
1100            self.ran.store(true, Ordering::SeqCst);
1101        }
1102    }
1103
1104    /// Serves `state` on an ephemeral port and returns once serving has ended.
1105    async fn serve_until_shutdown(
1106        state: RecordingState,
1107        shutdown: Shutdown,
1108    ) -> Result<(), WebServerError> {
1109        let router: Router = Router::new().route("/health", get(health));
1110        let cleanup: RecordingState = state.clone();
1111        // Port 0 asks the OS for a free one; nothing here connects to it.
1112        serve_on(router, "127.0.0.1", 0, shutdown, async move {
1113            cleanup.on_shutdown().await;
1114        })
1115        .await
1116    }
1117
1118    #[tokio::test]
1119    async fn app_cleanup_runs_when_the_shutdown_signal_arrives() {
1120        let shutdown: Shutdown = Shutdown::manual();
1121        let state: RecordingState = RecordingState::instant();
1122        let ran: Arc<AtomicBool> = Arc::clone(&state.ran);
1123
1124        shutdown.trigger();
1125        timeout(
1126            Duration::from_secs(1),
1127            serve_until_shutdown(state, shutdown.clone()),
1128        )
1129        .await
1130        .expect("serving ended promptly")
1131        .expect("serving ended cleanly");
1132
1133        let expected: bool = true;
1134        let actual: bool = ran.load(Ordering::SeqCst);
1135        assert_eq!(expected, actual);
1136    }
1137
1138    #[tokio::test]
1139    async fn app_cleanup_never_runs_when_the_server_dies_before_any_signal() {
1140        // An unparseable host fails before the listener binds, so no signal is
1141        // ever sent — the hook must be dropped unrun rather than awaited, or a
1142        // failed boot would hang until the drain window closed.
1143        let shutdown: Shutdown = Shutdown::manual();
1144        let state: RecordingState = RecordingState::instant();
1145        let ran: Arc<AtomicBool> = Arc::clone(&state.ran);
1146        let cleanup: RecordingState = state.clone();
1147
1148        let router: Router = Router::new().route("/health", get(health));
1149        let result: Result<(), WebServerError> = timeout(
1150            Duration::from_secs(1),
1151            serve_on(router, "not-an-ip", 0, shutdown, async move {
1152                cleanup.on_shutdown().await;
1153            }),
1154        )
1155        .await
1156        .expect("a bind failure returns immediately, it does not wait for cleanup");
1157
1158        assert!(result.is_err(), "an unparseable host is a bind error");
1159
1160        let expected: bool = false;
1161        let actual: bool = ran.load(Ordering::SeqCst);
1162        assert_eq!(expected, actual);
1163    }
1164
1165    #[tokio::test(start_paused = true)]
1166    async fn a_cleanup_that_overruns_the_window_is_cancelled_rather_than_awaited() {
1167        // Longer than the ceiling, so the hook cannot finish. With a paused
1168        // clock this costs no real time; the assertion is that serving still
1169        // returns, which it cannot do if the hook is awaited to completion.
1170        let shutdown: Shutdown = Shutdown::manual();
1171        let state: RecordingState = RecordingState {
1172            ran: Arc::new(AtomicBool::new(false)),
1173            linger: Some(DEFAULT_DRAIN_TIMEOUT * 2),
1174        };
1175        let ran: Arc<AtomicBool> = Arc::clone(&state.ran);
1176
1177        shutdown.trigger();
1178        serve_until_shutdown(state, shutdown.clone())
1179            .await
1180            .expect("serving still ends cleanly when cleanup is cancelled");
1181
1182        let expected: bool = false;
1183        let actual: bool = ran.load(Ordering::SeqCst);
1184        assert_eq!(
1185            expected, actual,
1186            "the hook was cancelled at its await, so it never reached its final store"
1187        );
1188    }
1189
1190    #[tokio::test]
1191    async fn a_server_with_nothing_in_flight_stops_at_once_instead_of_waiting_out_the_window() {
1192        let shutdown: Shutdown = Shutdown::manual();
1193        let router: Router = Router::new().route("/health", get(health));
1194
1195        // Port 0 asks the OS for a free one; nothing here connects to it.
1196        let serving: tokio::task::JoinHandle<Result<(), WebServerError>> = tokio::spawn({
1197            let shutdown: Shutdown = shutdown.clone();
1198            async move { serve_on(router, "127.0.0.1", 0, shutdown, async {}).await }
1199        });
1200
1201        shutdown.trigger();
1202
1203        // Far below the drain window: an unconditional wait fails here, which
1204        // is the bug — the window is a ceiling, not a delay.
1205        timeout(Duration::from_secs(1), serving)
1206            .await
1207            .expect("the server returned as soon as the drain began")
1208            .expect("the serving task did not panic")
1209            .expect("serving ended cleanly");
1210    }
1211
1212    #[test]
1213    fn the_body_limit_accommodates_an_ordinary_form_post() {
1214        // 1 KiB — the previous default — rejected almost any real submission,
1215        // so every project had to override it.
1216        let expected: usize = 256 * 1024;
1217        let actual: usize = DEFAULT_BODY_LIMIT;
1218        assert_eq!(expected, actual);
1219    }
1220
1221    #[cfg(feature = "pages")]
1222    #[test]
1223    fn stripping_the_prefix_leaves_a_nestable_path() {
1224        let expected: String = String::from("/boggledygook-a3f2c1d8");
1225        let actual: String = super::strip_api_prefix("/api/v1/boggledygook-a3f2c1d8");
1226        assert_eq!(expected, actual);
1227    }
1228
1229    #[test]
1230    fn the_api_prefix_is_the_one_every_project_shares() {
1231        assert_eq!("/api/v1", API_PREFIX);
1232    }
1233}