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