perf(app-proxy): serve precompressed assets and pass through (#2165)

This commit is contained in:
Hampus
2026-08-30 22:52:15 +02:00
committed by GitHub
parent 72ffa3bd9a
commit a2d7f5e8cc
5 changed files with 553 additions and 16 deletions
+3
View File
@@ -84,6 +84,7 @@ FROM alpine:3.21 AS app-assets
ARG BUNDLE_LOCAL_ASSETS=true ARG BUNDLE_LOCAL_ASSETS=true
ARG PUBLIC_ASSET_BASE_URL="" ARG PUBLIC_ASSET_BASE_URL=""
COPY --from=app-build /usr/src/app/fluxer_app/dist /assets COPY --from=app-build /usr/src/app/fluxer_app/dist /assets
COPY fluxer_app_proxy/scripts/precompress_assets.sh /usr/local/bin/precompress_assets.sh
RUN if [ "${BUNDLE_LOCAL_ASSETS}" != "true" ]; then \ RUN if [ "${BUNDLE_LOCAL_ASSETS}" != "true" ]; then \
case "${PUBLIC_ASSET_BASE_URL}" in \ case "${PUBLIC_ASSET_BASE_URL}" in \
http://* | https://*) ;; \ http://* | https://*) ;; \
@@ -95,6 +96,8 @@ RUN if [ "${BUNDLE_LOCAL_ASSETS}" != "true" ]; then \
esac; \ esac; \
find /assets/assets -type f ! -name 'fonts-*.txt' -delete; \ find /assets/assets -type f ! -name 'fonts-*.txt' -delete; \
fi fi
RUN apk add --no-cache brotli \
&& /usr/local/bin/precompress_assets.sh /assets
# Stage for CI to extract the full dist (docker bake app-dist target) # Stage for CI to extract the full dist (docker bake app-dist target)
FROM alpine:3.21 AS app-dist FROM alpine:3.21 AS app-dist
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env sh
# SPDX-License-Identifier: AGPL-3.0-or-later
set -eu
if [ "${PRECOMPRESS_WORKER:-}" = "1" ]; then
source_file="$1"
source_size="$(wc -c <"$source_file")"
if brotli -q "$PRECOMPRESS_BROTLI_QUALITY" -f -o "$source_file.br" "$source_file" 2>/dev/null; then
if [ "$(wc -c <"$source_file.br")" -ge "$source_size" ]; then
rm -f "$source_file.br"
fi
else
rm -f "$source_file.br"
fi
if gzip -n "-$PRECOMPRESS_GZIP_LEVEL" -c "$source_file" >"$source_file.gz" 2>/dev/null; then
if [ "$(wc -c <"$source_file.gz")" -ge "$source_size" ]; then
rm -f "$source_file.gz"
fi
else
rm -f "$source_file.gz"
fi
exit 0
fi
root="${1:?usage: precompress_assets.sh <asset-root>}"
case "$0" in
/*) script="$0" ;;
*) script="$PWD/$0" ;;
esac
PRECOMPRESS_WORKER=1
PRECOMPRESS_BROTLI_QUALITY="${PRECOMPRESS_BROTLI_QUALITY:-11}"
PRECOMPRESS_GZIP_LEVEL="${PRECOMPRESS_GZIP_LEVEL:-9}"
export PRECOMPRESS_WORKER PRECOMPRESS_BROTLI_QUALITY PRECOMPRESS_GZIP_LEVEL
min_size="${PRECOMPRESS_MIN_SIZE_BYTES:-1024}"
jobs="${PRECOMPRESS_JOBS:-$(nproc 2>/dev/null || echo 1)}"
find "$root" -type f \
! -name '*.br' \
! -name '*.gz' \
-size +"${min_size}"c \
\( \
-name '*.css' -o \
-name '*.html' -o \
-name '*.js' -o \
-name '*.json' -o \
-name '*.map' -o \
-name '*.mjs' -o \
-name '*.svg' -o \
-name '*.txt' -o \
-name '*.wasm' -o \
-name '*.webmanifest' -o \
-name '*.xml' \
\) \
-print0 |
xargs -0 -r -n 1 -P "$jobs" "$script"
+8 -8
View File
@@ -2,8 +2,12 @@
use anyhow::Context; use anyhow::Context;
use fluxer_app_proxy::{ use fluxer_app_proxy::{
config::AppProxyConfig, discovery_cache::DiscoveryCache, geoip, config::AppProxyConfig,
invite_meta::InviteMetaResolver, routes::build_router, state::AppState, discovery_cache::DiscoveryCache,
geoip,
invite_meta::InviteMetaResolver,
routes::build_router,
state::{AppState, build_http_client},
}; };
use std::sync::Arc; use std::sync::Arc;
use tokio::{net::TcpListener, runtime::Builder}; use tokio::{net::TcpListener, runtime::Builder};
@@ -28,12 +32,8 @@ fn main() -> anyhow::Result<()> {
.context("failed to create Fluxer app proxy async runtime")?; .context("failed to create Fluxer app proxy async runtime")?;
runtime.block_on(async move { runtime.block_on(async move {
let http_client = reqwest::Client::builder() let http_client =
.connect_timeout(std::time::Duration::from_secs(5)) build_http_client().context("failed to build Fluxer app proxy HTTP client")?;
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::limited(2))
.build()
.context("failed to build Fluxer app proxy HTTP client")?;
let discovery_cache = Arc::new(DiscoveryCache::new()); let discovery_cache = Arc::new(DiscoveryCache::new());
if let Err(err) = discovery_cache if let Err(err) = discovery_cache
+468 -8
View File
@@ -8,13 +8,14 @@ use axum::{
http::{HeaderMap, HeaderValue, StatusCode, header}, http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use std::path::Path as FsPath; use std::path::{Path as FsPath, PathBuf};
use std::time::Duration; use std::time::Duration;
use super::file_stream::stream_file; use super::file_stream::stream_file;
use super::spa_static::{CORS_ALLOW_ANY_VALUE, asset_cache_control, guess_mime, is_font_mime}; use super::spa_static::{CORS_ALLOW_ANY_VALUE, asset_cache_control, guess_mime, is_font_mime};
const ASSET_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); const ASSET_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
const PRECOMPRESSED_VARIANTS: &[(&str, &str)] = &[("br", "br"), ("gzip", "gz")];
const MAX_ASSET_SIZE_BYTES: u64 = 100 * 1024 * 1024; const MAX_ASSET_SIZE_BYTES: u64 = 100 * 1024 * 1024;
const UPSTREAM_FAILURE_CACHE_CONTROL: &str = "no-store"; const UPSTREAM_FAILURE_CACHE_CONTROL: &str = "no-store";
const UPSTREAM_FAILURE_STRIPPED_HEADERS: &[&str] = &[ const UPSTREAM_FAILURE_STRIPPED_HEADERS: &[&str] = &[
@@ -26,7 +27,6 @@ const UPSTREAM_FAILURE_STRIPPED_HEADERS: &[&str] = &[
]; ];
const BLOCKED_REQUEST_HEADERS: &[&str] = &[ const BLOCKED_REQUEST_HEADERS: &[&str] = &[
"accept-encoding",
"authorization", "authorization",
"connection", "connection",
"cookie", "cookie",
@@ -118,14 +118,12 @@ pub async fn proxy_assets(
if BLOCKED_RESPONSE_HEADERS.contains(&name_str) { if BLOCKED_RESPONSE_HEADERS.contains(&name_str) {
continue; continue;
} }
if name_str == "content-encoding" || name_str == "content-length" {
continue;
}
response_headers.insert(name.clone(), value.clone()); response_headers.insert(name.clone(), value.clone());
} }
set_known_asset_content_type(&mut response_headers, &path); set_known_asset_content_type(&mut response_headers, &path);
set_font_cors(&mut response_headers); set_font_cors(&mut response_headers);
set_proxied_cache_control(&mut response_headers, &path, status); set_proxied_cache_control(&mut response_headers, &path, status);
set_vary_on_accept_encoding(&mut response_headers);
let asset_csp = build_asset_csp( let asset_csp = build_asset_csp(
&state.config.csp, &state.config.csp,
@@ -169,7 +167,10 @@ pub(super) async fn serve_local_asset(
return StatusCode::NOT_FOUND.into_response(); return StatusCode::NOT_FOUND.into_response();
} }
let entity_tag = tokio::fs::metadata(&resolved) let (served_path, content_encoding) =
select_precompressed_variant(&resolved, &base, request_headers).await;
let entity_tag = tokio::fs::metadata(&served_path)
.await .await
.ok() .ok()
.and_then(|metadata| local_asset_entity_tag(&metadata)); .and_then(|metadata| local_asset_entity_tag(&metadata));
@@ -182,7 +183,8 @@ pub(super) async fn serve_local_asset(
return response; return response;
} }
let mut response = match stream_file(&resolved, request_headers, entity_tag.as_deref()).await { let mut response = match stream_file(&served_path, request_headers, entity_tag.as_deref()).await
{
Ok(response) => response, Ok(response) => response,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => { Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return StatusCode::NOT_FOUND.into_response(); return StatusCode::NOT_FOUND.into_response();
@@ -197,15 +199,82 @@ pub(super) async fn serve_local_asset(
if let Ok(value) = HeaderValue::from_str(mime_type) { if let Ok(value) = HeaderValue::from_str(mime_type) {
response.headers_mut().insert(header::CONTENT_TYPE, value); response.headers_mut().insert(header::CONTENT_TYPE, value);
} }
if let Some(content_encoding) = content_encoding {
response.headers_mut().insert(
header::CONTENT_ENCODING,
HeaderValue::from_static(content_encoding),
);
}
set_local_asset_headers(response.headers_mut(), relative_path, entity_tag.as_deref()); set_local_asset_headers(response.headers_mut(), relative_path, entity_tag.as_deref());
response response
} }
async fn select_precompressed_variant(
resolved: &FsPath,
base: &FsPath,
request_headers: &HeaderMap,
) -> (PathBuf, Option<&'static str>) {
for &(encoding, extension) in PRECOMPRESSED_VARIANTS {
if !accepts_encoding(request_headers, encoding) {
continue;
}
let Some(candidate) = usable_sibling(resolved, base, extension).await else {
continue;
};
return (candidate, Some(encoding));
}
(resolved.to_path_buf(), None)
}
async fn usable_sibling(resolved: &FsPath, base: &FsPath, extension: &str) -> Option<PathBuf> {
let mut name = resolved.as_os_str().to_owned();
name.push(".");
name.push(extension);
let candidate = tokio::fs::canonicalize(PathBuf::from(name)).await.ok()?;
if !candidate.starts_with(base) {
return None;
}
tokio::fs::metadata(&candidate)
.await
.ok()
.filter(std::fs::Metadata::is_file)
.map(|_| candidate)
}
fn accepts_encoding(headers: &HeaderMap, encoding: &str) -> bool {
let Some(header_value) = headers
.get(header::ACCEPT_ENCODING)
.and_then(|value| value.to_str().ok())
else {
return false;
};
header_value.split(',').any(|candidate| {
let mut parts = candidate.split(';').map(str::trim);
let Some(name) = parts.next() else {
return false;
};
name.eq_ignore_ascii_case(encoding) && !parts.any(is_zero_quality)
})
}
fn is_zero_quality(parameter: &str) -> bool {
let Some((key, value)) = parameter.split_once('=') else {
return false;
};
key.trim().eq_ignore_ascii_case("q")
&& value
.trim()
.parse::<f32>()
.is_ok_and(|quality| quality <= 0.0)
}
fn set_local_asset_headers(headers: &mut HeaderMap, relative_path: &str, entity_tag: Option<&str>) { fn set_local_asset_headers(headers: &mut HeaderMap, relative_path: &str, entity_tag: Option<&str>) {
headers.insert( headers.insert(
header::CACHE_CONTROL, header::CACHE_CONTROL,
HeaderValue::from_static(asset_cache_control(relative_path)), HeaderValue::from_static(asset_cache_control(relative_path)),
); );
set_vary_on_accept_encoding(headers);
if is_font_mime(guess_mime(relative_path)) { if is_font_mime(guess_mime(relative_path)) {
headers.insert( headers.insert(
header::ACCESS_CONTROL_ALLOW_ORIGIN, header::ACCESS_CONTROL_ALLOW_ORIGIN,
@@ -258,6 +327,20 @@ fn set_proxied_cache_control(headers: &mut HeaderMap, path: &str, status: Status
} }
} }
fn set_vary_on_accept_encoding(headers: &mut HeaderMap) {
let already_varies = headers.get_all(header::VARY).iter().any(|value| {
value.to_str().is_ok_and(|value| {
value.split(',').any(|field| {
let field = field.trim();
field == "*" || field.eq_ignore_ascii_case("accept-encoding")
})
})
});
if !already_varies {
headers.append(header::VARY, HeaderValue::from_static("accept-encoding"));
}
}
fn set_font_cors(headers: &mut HeaderMap) { fn set_font_cors(headers: &mut HeaderMap) {
let is_font = headers let is_font = headers
.get(header::CONTENT_TYPE) .get(header::CONTENT_TYPE)
@@ -290,12 +373,14 @@ mod tests {
use super::*; use super::*;
use crate::config::AppProxyConfig; use crate::config::AppProxyConfig;
use crate::discovery_cache::DiscoveryCache; use crate::discovery_cache::DiscoveryCache;
use crate::state::build_http_client;
use axum::Router; use axum::Router;
use axum::http::Request as HttpRequest; use axum::http::Request as HttpRequest;
use axum::http::header::HeaderName; use axum::http::header::HeaderName;
use fluxer_common::config::GeoipSourceConfig; use fluxer_common::config::GeoipSourceConfig;
use fluxer_common::geoip::{GeoipConfig, GeoipResolver}; use fluxer_common::geoip::{GeoipConfig, GeoipResolver};
use std::sync::Arc; use std::sync::Arc;
use tower::ServiceExt;
async fn spawn_upstream(status: StatusCode, cache_control: &'static str) -> String { async fn spawn_upstream(status: StatusCode, cache_control: &'static str) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -318,9 +403,20 @@ mod tests {
fn upstream_backed_state(cdn_endpoint: &str) -> AppState { fn upstream_backed_state(cdn_endpoint: &str) -> AppState {
let mut config = AppProxyConfig::from_env(); let mut config = AppProxyConfig::from_env();
config.static_cdn_endpoint = Some(cdn_endpoint.to_owned()); config.static_cdn_endpoint = Some(cdn_endpoint.to_owned());
state_from_config(config)
}
fn locally_backed_state(static_dir: &str) -> AppState {
let mut config = AppProxyConfig::from_env();
config.static_cdn_endpoint = None;
config.static_dir = static_dir.to_owned();
state_from_config(config)
}
fn state_from_config(config: AppProxyConfig) -> AppState {
AppState { AppState {
config: Arc::new(config), config: Arc::new(config),
http_client: reqwest::Client::new(), http_client: build_http_client().unwrap(),
discovery_cache: Arc::new(DiscoveryCache::new()), discovery_cache: Arc::new(DiscoveryCache::new()),
geoip: Arc::new(GeoipResolver::from_config(&GeoipConfig { geoip: Arc::new(GeoipResolver::from_config(&GeoipConfig {
geoip_source: GeoipSourceConfig::Filesystem { geoip_source: GeoipSourceConfig::Filesystem {
@@ -543,6 +639,11 @@ mod tests {
Self { root } Self { root }
} }
fn and_sibling(self, name: &str, bytes: &[u8]) -> Self {
std::fs::write(self.root.join("assets").join(name), bytes).unwrap();
self
}
fn dir(&self) -> &str { fn dir(&self) -> &str {
self.root.to_str().unwrap() self.root.to_str().unwrap()
} }
@@ -719,6 +820,365 @@ mod tests {
assert!(is_hashed_asset("assets/2d715e4730758083.worker.js")); assert!(is_hashed_asset("assets/2d715e4730758083.worker.js"));
} }
fn accept_encoding(value: &'static str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static(value));
headers
}
fn content_encoding_of(response: &Response) -> Option<&str> {
response
.headers()
.get(header::CONTENT_ENCODING)
.and_then(|value| value.to_str().ok())
}
fn varies_on_accept_encoding(response: &Response) -> bool {
response
.headers()
.get_all(header::VARY)
.iter()
.any(|value| {
value
.to_str()
.is_ok_and(|value| value.eq_ignore_ascii_case("accept-encoding"))
})
}
async fn body_bytes(response: Response) -> Vec<u8> {
axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap()
.to_vec()
}
#[tokio::test]
async fn a_local_asset_is_served_from_its_precompressed_brotli_sibling() {
let fixture = LocalAssetDir::with_asset("356aaade04a117b1.js", b"console.log(1)")
.and_sibling("356aaade04a117b1.js.br", b"brotli-bytes");
let response = serve_local_asset(
fixture.dir(),
"assets/356aaade04a117b1.js",
&accept_encoding("gzip, deflate, br, zstd"),
)
.await;
assert_eq!(content_encoding_of(&response), Some("br"));
assert!(varies_on_accept_encoding(&response));
assert_eq!(
response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("application/javascript; charset=utf-8"),
"the encoding must not leak into the media type the browser parses"
);
assert_eq!(
body_bytes(response).await,
b"brotli-bytes",
"the sibling produced at build time must reach the wire unmodified"
);
}
#[tokio::test]
async fn a_local_asset_falls_back_to_the_raw_file_without_a_sibling() {
let fixture = LocalAssetDir::with_asset("469e0b8f10c496a1.css", b"body{color:red}");
let response = serve_local_asset(
fixture.dir(),
"assets/469e0b8f10c496a1.css",
&accept_encoding("gzip, deflate, br"),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(content_encoding_of(&response), None);
assert!(varies_on_accept_encoding(&response));
assert_eq!(body_bytes(response).await, b"body{color:red}");
}
#[tokio::test]
async fn a_local_asset_only_uses_an_encoding_the_client_accepted() {
let fixture = LocalAssetDir::with_asset("488b87159423ca35.js", b"console.log(2)")
.and_sibling("488b87159423ca35.js.br", b"brotli-bytes")
.and_sibling("488b87159423ca35.js.gz", b"gzip-bytes");
let gzip_only = serve_local_asset(
fixture.dir(),
"assets/488b87159423ca35.js",
&accept_encoding("gzip, deflate"),
)
.await;
assert_eq!(content_encoding_of(&gzip_only), Some("gzip"));
assert_eq!(body_bytes(gzip_only).await, b"gzip-bytes");
let identity = serve_local_asset(
fixture.dir(),
"assets/488b87159423ca35.js",
&HeaderMap::new(),
)
.await;
assert_eq!(
content_encoding_of(&identity),
None,
"a client that advertised no encoding cannot decode the sibling"
);
assert_eq!(body_bytes(identity).await, b"console.log(2)");
}
#[tokio::test]
async fn a_local_asset_refuses_a_sibling_the_client_scored_zero() {
let fixture = LocalAssetDir::with_asset("2d715e4730758083.worker.js", b"self.onmessage=0")
.and_sibling("2d715e4730758083.worker.js.br", b"brotli-bytes");
let response = serve_local_asset(
fixture.dir(),
"assets/2d715e4730758083.worker.js",
&accept_encoding("br;q=0, gzip"),
)
.await;
assert_eq!(content_encoding_of(&response), None);
assert_eq!(body_bytes(response).await, b"self.onmessage=0");
}
#[tokio::test]
async fn a_precompressed_variant_carries_its_own_validator() {
let fixture = LocalAssetDir::with_asset("f00dcafe12345678.css", b"body{}")
.and_sibling("f00dcafe12345678.css.br", b"brotli-bytes-are-longer");
let brotli = serve_local_asset(
fixture.dir(),
"assets/f00dcafe12345678.css",
&accept_encoding("br"),
)
.await;
let brotli_tag = entity_tag_of(&brotli).expect("the brotli variant carries a validator");
let identity = serve_local_asset(
fixture.dir(),
"assets/f00dcafe12345678.css",
&HeaderMap::new(),
)
.await;
let identity_tag = entity_tag_of(&identity).expect("the raw file carries a validator");
assert_ne!(
brotli_tag, identity_tag,
"two encodings sharing one validator let a cache hand brotli to a client that asked for identity"
);
let mut conditional = accept_encoding("br");
conditional.insert(
header::IF_NONE_MATCH,
HeaderValue::from_str(&brotli_tag).unwrap(),
);
let revalidated =
serve_local_asset(fixture.dir(), "assets/f00dcafe12345678.css", &conditional).await;
assert_eq!(revalidated.status(), StatusCode::NOT_MODIFIED);
assert!(varies_on_accept_encoding(&revalidated));
}
#[tokio::test]
async fn a_range_over_a_precompressed_sibling_describes_the_encoded_bytes() {
let fixture = LocalAssetDir::with_asset("356aaade04a117b1.js", b"console.log(1)")
.and_sibling("356aaade04a117b1.js.br", b"0123456789");
let mut ranged = accept_encoding("br");
ranged.insert(header::RANGE, HeaderValue::from_static("bytes=4-6"));
let response =
serve_local_asset(fixture.dir(), "assets/356aaade04a117b1.js", &ranged).await;
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(content_encoding_of(&response), Some("br"));
assert_eq!(
response
.headers()
.get(header::CONTENT_RANGE)
.and_then(|value| value.to_str().ok()),
Some("bytes 4-6/10"),
"a range counted over the raw file cannot be reassembled from the encoded bytes we sent"
);
assert_eq!(
response
.headers()
.get(header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok()),
Some("3")
);
assert_eq!(body_bytes(response).await, b"456");
}
async fn spawn_encoded_upstream(content_encoding: &'static str, body: &'static str) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let router = Router::new().fallback(move |request: HttpRequest<Body>| async move {
let echoed = request
.headers()
.get(header::ACCEPT_ENCODING)
.and_then(|value| value.to_str().ok())
.unwrap_or("<absent>")
.to_owned();
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
header::CONTENT_ENCODING,
HeaderValue::from_static(content_encoding),
);
response.headers_mut().insert(
HeaderName::from_static("x-echoed-accept-encoding"),
HeaderValue::from_str(&echoed).unwrap(),
);
response
});
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
format!("http://{addr}")
}
#[tokio::test]
async fn a_cdn_backed_asset_streams_the_upstream_encoding_untouched() {
let endpoint = spawn_encoded_upstream("br", "already-brotli").await;
let state = upstream_backed_state(&endpoint);
let request = HttpRequest::builder()
.uri("/assets/356aaade04a117b1.js")
.header(header::ACCEPT_ENCODING, "gzip, deflate, br")
.body(Body::empty())
.unwrap();
let response = proxy_assets(
State(state),
Path("356aaade04a117b1.js".to_owned()),
request,
)
.await;
assert_eq!(
response
.headers()
.get("x-echoed-accept-encoding")
.and_then(|value| value.to_str().ok()),
Some("gzip, deflate, br"),
"blocking accept-encoding forces the origin to hand us bytes it already had compressed"
);
assert_eq!(
content_encoding_of(&response),
Some("br"),
"dropping content-encoding turns compressed upstream bytes into an undecodable body"
);
assert!(varies_on_accept_encoding(&response));
assert_eq!(body_bytes(response).await, b"already-brotli");
}
#[tokio::test]
async fn a_cdn_backed_asset_keeps_the_upstream_content_length() {
let endpoint = spawn_encoded_upstream("gzip", "0123456789").await;
let state = upstream_backed_state(&endpoint);
let request = HttpRequest::builder()
.uri("/assets/voice_engine_bg.wasm")
.header(header::ACCEPT_ENCODING, "gzip")
.body(Body::empty())
.unwrap();
let response = proxy_assets(
State(state),
Path("voice_engine_bg.wasm".to_owned()),
request,
)
.await;
assert_eq!(
response
.headers()
.get(header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok()),
Some("10"),
"a client that cannot see the encoded length cannot show download progress"
);
}
const COMPRESSIBLE_BODY: &[u8] =
b"the default compression predicate ignores anything under thirty-two bytes";
#[tokio::test]
async fn an_asset_without_a_sibling_is_still_compressed_before_it_leaves() {
let fixture = LocalAssetDir::with_asset("356aaade04a117b1.js", COMPRESSIBLE_BODY);
let router = super::super::build_router(locally_backed_state(fixture.dir()));
let asset = router
.oneshot(
HttpRequest::builder()
.uri("/assets/356aaade04a117b1.js")
.header(header::ACCEPT_ENCODING, "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(asset.status(), StatusCode::OK);
assert_eq!(
content_encoding_of(&asset),
Some("gzip"),
"an extension the build-time step does not cover must not fall off a bandwidth cliff"
);
assert_ne!(body_bytes(asset).await, COMPRESSIBLE_BODY);
}
#[tokio::test]
async fn a_passed_through_cdn_encoding_is_never_recompressed_by_the_layer() {
let endpoint = spawn_encoded_upstream(
"br",
"already brotli, and long enough to clear the thirty-two byte floor",
)
.await;
let router = super::super::build_router(upstream_backed_state(&endpoint));
let response = router
.oneshot(
HttpRequest::builder()
.uri("/assets/356aaade04a117b1.js")
.header(header::ACCEPT_ENCODING, "gzip, deflate, br")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(content_encoding_of(&response), Some("br"));
assert_eq!(
body_bytes(response).await,
b"already brotli, and long enough to clear the thirty-two byte floor",
"re-encoding upstream bytes that already carry an encoding breaks every browser"
);
}
#[tokio::test]
async fn a_precompressed_sibling_reaches_the_client_through_the_router() {
let fixture = LocalAssetDir::with_asset("488b87159423ca35.js", COMPRESSIBLE_BODY)
.and_sibling("488b87159423ca35.js.br", b"brotli-bytes");
let router = super::super::build_router(locally_backed_state(fixture.dir()));
let response = router
.oneshot(
HttpRequest::builder()
.uri("/assets/488b87159423ca35.js")
.header(header::ACCEPT_ENCODING, "gzip, deflate, br")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(content_encoding_of(&response), Some("br"));
assert_eq!(
body_bytes(response).await,
b"brotli-bytes",
"re-encoding the sibling would double-compress it and break every browser"
);
}
#[test] #[test]
fn known_js_asset_overrides_upstream_octet_stream() { fn known_js_asset_overrides_upstream_octet_stream() {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
+12
View File
@@ -5,6 +5,7 @@ use crate::discovery_cache::DiscoveryCache;
use crate::invite_meta::InviteMetaResolver; use crate::invite_meta::InviteMetaResolver;
use fluxer_common::geoip::GeoipResolver; use fluxer_common::geoip::GeoipResolver;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@@ -15,3 +16,14 @@ pub struct AppState {
pub invite_meta: Option<Arc<InviteMetaResolver>>, pub invite_meta: Option<Arc<InviteMetaResolver>>,
pub index_html: Option<Arc<str>>, pub index_html: Option<Arc<str>>,
} }
pub fn build_http_client() -> reqwest::Result<reqwest::Client> {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::limited(2))
.no_gzip()
.no_brotli()
.no_deflate()
.build()
}