Skip to main content

wire/server/
bundle.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Serving the client bundle over HTTP: headers on, policy out. Which
7//! bytes answer a path is `wire::assets`' decision.
8
9use std::sync::Arc;
10
11use axum::Router;
12use axum::extract::State;
13use axum::http::{StatusCode, header};
14use axum::middleware::from_fn_with_state;
15use axum::response::{IntoResponse, Response};
16use axum::routing::get;
17use kernel::AxError;
18
19use crate::assets::{AssetReply, ClientAssets};
20use crate::reception::PageHeaders;
21
22use super::config::refusal_text;
23use super::guard::{Stamp, Stored, stamp};
24
25/// The two routes that answer the client bundle: `/` and every path
26/// below it that no other route of the same table claims.
27///
28/// The city's own port and the remote listener (`bin::outside`) serve
29/// the page through this one table, so a device that opens the remote
30/// address gets the same bytes as a browser on this machine
31/// (`crates/wire/Spec.lean` §8-2); `headers` are the listener's own,
32/// generated once per listener (`crates/wire/spec/Reception/Entry.lean`
33/// §8-94). The routes take no credential: a browser has to load the page
34/// before it has anywhere to pair.
35///
36/// # Errors
37/// `headers` cannot be spelled as HTTP headers.
38pub fn bundle_routes<S>(
39    client: Arc<ClientAssets>,
40    headers: &PageHeaders,
41) -> Result<Router<S>, AxError>
42where
43    S: Clone + Send + Sync + 'static,
44{
45    let stamped = Arc::new(Stamp::of(headers, Stored::AsPage)?);
46    Ok(Router::new()
47        .route("/", get(serve_index))
48        .route("/{*asset}", get(serve_asset))
49        .with_state(client)
50        .layer(from_fn_with_state(stamped, stamp)))
51}
52
53async fn serve_index(State(client): State<Arc<ClientAssets>>) -> Response {
54    asset_response(client.lookup("index.html"))
55}
56
57async fn serve_asset(
58    State(client): State<Arc<ClientAssets>>,
59    axum::extract::Path(asset): axum::extract::Path<String>,
60) -> Response {
61    asset_response(client.lookup(&asset))
62}
63
64/// The shell around [`ClientAssets::lookup`]: headers on, policy out.
65fn asset_response(reply: AssetReply) -> Response {
66    match reply {
67        AssetReply::Found {
68            bytes,
69            content_type,
70            gzipped: true,
71        } => (
72            StatusCode::OK,
73            [
74                (header::CONTENT_TYPE, content_type),
75                (header::CONTENT_ENCODING, "gzip"),
76            ],
77            bytes,
78        )
79            .into_response(),
80        AssetReply::Found {
81            bytes,
82            content_type,
83            gzipped: false,
84        } => (
85            StatusCode::OK,
86            [(header::CONTENT_TYPE, content_type)],
87            bytes,
88        )
89            .into_response(),
90        AssetReply::Miss(err) => (StatusCode::NOT_FOUND, refusal_text(&err)).into_response(),
91    }
92}