Skip to content

Commit 10620fe

Browse files
committed
chore: new crate axum-dpc-static-asset
1 parent cbacc6c commit 10620fe

File tree

9 files changed

+193
-150
lines changed

9 files changed

+193
-150
lines changed

Cargo.lock

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ members = [
1111
"crates/rostra-util-error",
1212
"crates/rostra-util-fmt",
1313
"crates/rostra-web-ui",
14+
"crates/axum-dpc-static-assets",
1415
]
1516
resolver = "2"
1617

@@ -34,6 +35,7 @@ license = "MIT"
3435
async-stream = "0.3.6"
3536
anyhow = "1.0.95"
3637
axum = { version = "0.8.1", features = ["macros"] }
38+
axum-dpc-static-assets = { path = "crates/axum-dpc-static-assets" }
3739
axum-extra = { version = "0.10.0" }
3840
backon = "1.3.0"
3941
bao-tree = "0.13"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[package]
2+
name = "axum-dpc-static-assets"
3+
4+
description = "dpc's static asset handler for axum"
5+
edition = "2024"
6+
license = "MIT OR APACHE-2.0 OR MPL-2.0"
7+
version = "0.1.0"
8+
9+
[lints]
10+
workspace = true
11+
12+
[dependencies]
13+
async-stream = { workspace = true }
14+
axum = { workspace = true, features = ["ws", "multipart"] }
15+
bytes = { workspace = true }
16+
brotli = { workspace = true }
17+
futures = { workspace = true }
18+
snafu = { workspace = true }
19+
tokio = { workspace = true }
20+
tokio-stream = { workspace = true, features = ["fs"] }
21+
tower-service = { workspace = true }
22+
tracing = { workspace = true }
Lines changed: 144 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,66 @@ use std::hash::{Hash, Hasher};
55
use std::io::{self, Write as _};
66
use std::path::{self, PathBuf};
77
use std::string::String;
8-
use std::sync::LazyLock;
8+
use std::sync::{Arc, LazyLock};
9+
use std::task::{Context, Poll};
910

11+
use axum::body::Body;
12+
use axum::http::header::{ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_TYPE};
13+
use axum::http::{HeaderMap, HeaderValue, Request, Response, StatusCode};
14+
use axum::response::IntoResponse;
1015
use bytes::Bytes;
16+
use futures::future::BoxFuture;
1117
use futures::stream::{BoxStream, StreamExt};
12-
use rostra_util_error::WhateverResult;
13-
use snafu::{OptionExt as _, ResultExt as _};
18+
use snafu::{OptionExt as _, ResultExt as _, Snafu};
1419
use tokio_stream::wrappers::ReadDirStream;
20+
use tower_service::Service;
1521
use tracing::{debug, info};
1622

17-
use crate::LOG_TARGET;
23+
const LOG_TARGET: &str = "axum::dpc";
24+
25+
pub type BoxedError = Box<dyn std::error::Error + Send + Sync + 'static>;
26+
pub type BoxedErrorResult<T> = std::result::Result<T, BoxedError>;
27+
28+
/// Handles ETag-based conditional requests
29+
///
30+
/// Takes the request headers, the ETag value, and response headers to modify.
31+
/// If the client already has the current version (based on If-None-Match
32+
/// header), returns a 304 Not Modified response.
33+
///
34+
/// Returns:
35+
/// - Some(Response) if a 304 Not Modified should be returned
36+
/// - None if processing should continue normally
37+
pub fn handle_etag(
38+
req_headers: &axum::http::HeaderMap,
39+
etag: &str,
40+
resp_headers: &mut axum::http::HeaderMap,
41+
) -> Option<axum::response::Response> {
42+
use axum::http::StatusCode;
43+
use axum::http::header::{ETAG, IF_NONE_MATCH};
44+
use axum::response::IntoResponse;
45+
46+
// Add ETag header to response
47+
if let Ok(etag_value) = axum::http::HeaderValue::from_str(etag) {
48+
resp_headers.insert(ETAG, etag_value);
49+
}
50+
51+
// Check if client already has this version
52+
if let Some(if_none_match) = req_headers.get(IF_NONE_MATCH) {
53+
if if_none_match.as_bytes() == etag.as_bytes() {
54+
return Some((StatusCode::NOT_MODIFIED, resp_headers.clone()).into_response());
55+
}
56+
}
57+
58+
None
59+
}
60+
61+
#[derive(Debug, Snafu)]
62+
pub enum LoadError {
63+
#[snafu(display("IO error for {}", path.display()))]
64+
IO { source: io::Error, path: PathBuf },
65+
#[snafu(display("Invalid path: {}", path.display()))]
66+
InvalidPath { path: PathBuf },
67+
}
1868

1969
/// Pre-loaded and pre-compressed static assets. This
2070
/// is used to serve static assets from the build directory without reading from
@@ -23,17 +73,15 @@ use crate::LOG_TARGET;
2373
pub struct StaticAssets(HashMap<String, StaticAsset>);
2474

2575
impl StaticAssets {
26-
pub async fn load(root_dir: &path::Path) -> WhateverResult<Self> {
76+
pub async fn load(root_dir: &path::Path) -> Result<Self, LoadError> {
2777
info!(target: LOG_TARGET, dir=%root_dir.display(), "Loading assets");
2878
let mut cache = HashMap::default();
2979

30-
let assets: Vec<WhateverResult<(String, StaticAsset)>> =
80+
let assets: Vec<Result<(String, StaticAsset), LoadError>> =
3181
read_dir_stream(root_dir.to_owned())
3282
.map(|file| async move {
33-
let path = file.whatever_context("Failed to read file metadata")?;
34-
// let filename = path.file_name().and_then(|n| n.to_str());
83+
let path = file.with_context(|_e| IOSnafu { path: root_dir.to_owned() })?;
3584
let filename = path.strip_prefix(root_dir).expect("Can't fail").to_str();
36-
// .and_then(|n| n.to_str());
3785
let ext = path.extension().and_then(|p| p.to_str());
3886

3987
let (filename, ext) = match (filename, ext) {
@@ -46,12 +94,12 @@ impl StaticAssets {
4694
.into_os_string()
4795
.into_string()
4896
.ok()
49-
.whatever_context("Invalid path")?;
97+
.with_context(|| InvalidPathSnafu { path: path.to_owned() })?;
5098
tracing::debug!(path = %stored_path, "Loading asset");
5199

52100
let raw = tokio::fs::read(&path)
53101
.await
54-
.whatever_context("Could not read file")?;
102+
.with_context(|_e| IOSnafu { path: path.to_owned()})?;
55103

56104
let compressed = match ext {
57105
"css" | "js" | "svg" | "json" => Some(compress_data(&raw)),
@@ -87,7 +135,7 @@ impl StaticAssets {
87135
})
88136
.buffered(32)
89137
.filter_map(
90-
|res_opt: WhateverResult<std::option::Option<(String, StaticAsset)>>| {
138+
|res_opt: Result<std::option::Option<(String, StaticAsset)>, LoadError>| {
91139
ready(res_opt.transpose())
92140
},
93141
)
@@ -102,7 +150,7 @@ impl StaticAssets {
102150
for (key, asset) in &cache {
103151
tracing::debug!(%key, path = %asset.path, "Asset loaded");
104152
}
105-
tracing::debug!(len = cache.len(), "Loaded assets");
153+
tracing::debug!(target: LOG_TARGET, len = cache.len(), "Loaded assets");
106154

107155
Ok(Self(cache))
108156
}
@@ -203,3 +251,86 @@ fn read_dir_stream(dir: PathBuf) -> BoxStream<'static, io::Result<PathBuf>> {
203251
}
204252
.boxed()
205253
}
254+
255+
#[derive(Clone)]
256+
pub struct StaticAssetService {
257+
assets: Arc<StaticAssets>,
258+
}
259+
260+
impl StaticAssetService {
261+
pub fn new(assets: Arc<StaticAssets>) -> Self {
262+
Self { assets }
263+
}
264+
265+
fn handle_request(&self, req: Request<Body>) -> Response<Body> {
266+
let path = req.uri().path().trim_start_matches('/');
267+
let Some(asset) = self.assets.get(path) else {
268+
dbg!("NOT FOUND", path);
269+
return (StatusCode::NOT_FOUND, Body::empty()).into_response();
270+
};
271+
272+
let req_headers = req.headers();
273+
let mut resp_headers = HeaderMap::new();
274+
275+
// Set content type
276+
resp_headers.insert(
277+
CONTENT_TYPE,
278+
HeaderValue::from_static(asset.content_type().unwrap_or("application/octet-stream")),
279+
);
280+
281+
// Handle ETag and conditional request
282+
let etag = asset.etag.clone();
283+
if let Some(response) = crate::handle_etag(req_headers, &etag, &mut resp_headers) {
284+
return response;
285+
}
286+
287+
let accepts_brotli = req_headers
288+
.get_all(ACCEPT_ENCODING)
289+
.into_iter()
290+
.any(|encodings| {
291+
let Ok(str) = encodings.to_str() else {
292+
return false;
293+
};
294+
295+
str.split(',').any(|s| s.trim() == "br")
296+
});
297+
298+
let content = match (accepts_brotli, asset.compressed.as_ref()) {
299+
(true, Some(compressed)) => {
300+
resp_headers.insert(CONTENT_ENCODING, HeaderValue::from_static("br"));
301+
compressed.clone()
302+
}
303+
_ => asset.raw.clone(),
304+
};
305+
306+
(resp_headers, content).into_response()
307+
}
308+
}
309+
310+
impl<B> Service<Request<B>> for StaticAssetService
311+
where
312+
B: Send + 'static,
313+
{
314+
type Response = Response<Body>;
315+
type Error = std::convert::Infallible;
316+
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
317+
318+
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), std::convert::Infallible>> {
319+
Poll::Ready(Ok(()))
320+
}
321+
322+
fn call(
323+
&mut self,
324+
req: Request<B>,
325+
) -> BoxFuture<'static, Result<Response<Body>, std::convert::Infallible>> {
326+
let service = self.clone();
327+
let uri = req.uri().clone();
328+
329+
Box::pin(async move {
330+
// Convert to a Request<Body> by extracting just the URI
331+
let new_req = Request::builder().uri(uri).body(Body::empty()).unwrap();
332+
333+
Ok(service.handle_request(new_req))
334+
})
335+
}
336+
}

crates/rostra-web-ui/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ workspace = true
1313
async-stream = { workspace = true }
1414
axum = { workspace = true, features = ["ws", "multipart"] }
1515
axum-extra = { workspace = true, features = ["form"] }
16+
axum-dpc-static-assets = { workspace = true }
1617
bon = { workspace = true }
1718
bytes = { workspace = true }
1819
brotli = { workspace = true }

crates/rostra-web-ui/src/asset_service.rs

Lines changed: 0 additions & 94 deletions
This file was deleted.

0 commit comments

Comments
 (0)