mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
perf(app-proxy): serve precompressed assets and pass through (#2165)
This commit is contained in:
@@ -84,6 +84,7 @@ FROM alpine:3.21 AS app-assets
|
||||
ARG BUNDLE_LOCAL_ASSETS=true
|
||||
ARG PUBLIC_ASSET_BASE_URL=""
|
||||
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 \
|
||||
case "${PUBLIC_ASSET_BASE_URL}" in \
|
||||
http://* | https://*) ;; \
|
||||
@@ -95,6 +96,8 @@ RUN if [ "${BUNDLE_LOCAL_ASSETS}" != "true" ]; then \
|
||||
esac; \
|
||||
find /assets/assets -type f ! -name 'fonts-*.txt' -delete; \
|
||||
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)
|
||||
FROM alpine:3.21 AS app-dist
|
||||
|
||||
+62
@@ -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"
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
use anyhow::Context;
|
||||
use fluxer_app_proxy::{
|
||||
config::AppProxyConfig, discovery_cache::DiscoveryCache, geoip,
|
||||
invite_meta::InviteMetaResolver, routes::build_router, state::AppState,
|
||||
config::AppProxyConfig,
|
||||
discovery_cache::DiscoveryCache,
|
||||
geoip,
|
||||
invite_meta::InviteMetaResolver,
|
||||
routes::build_router,
|
||||
state::{AppState, build_http_client},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::{net::TcpListener, runtime::Builder};
|
||||
@@ -28,12 +32,8 @@ fn main() -> anyhow::Result<()> {
|
||||
.context("failed to create Fluxer app proxy async runtime")?;
|
||||
|
||||
runtime.block_on(async move {
|
||||
let http_client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::limited(2))
|
||||
.build()
|
||||
.context("failed to build Fluxer app proxy HTTP client")?;
|
||||
let http_client =
|
||||
build_http_client().context("failed to build Fluxer app proxy HTTP client")?;
|
||||
let discovery_cache = Arc::new(DiscoveryCache::new());
|
||||
|
||||
if let Err(err) = discovery_cache
|
||||
|
||||
@@ -8,13 +8,14 @@ use axum::{
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use std::path::Path as FsPath;
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::file_stream::stream_file;
|
||||
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 PRECOMPRESSED_VARIANTS: &[(&str, &str)] = &[("br", "br"), ("gzip", "gz")];
|
||||
const MAX_ASSET_SIZE_BYTES: u64 = 100 * 1024 * 1024;
|
||||
const UPSTREAM_FAILURE_CACHE_CONTROL: &str = "no-store";
|
||||
const UPSTREAM_FAILURE_STRIPPED_HEADERS: &[&str] = &[
|
||||
@@ -26,7 +27,6 @@ const UPSTREAM_FAILURE_STRIPPED_HEADERS: &[&str] = &[
|
||||
];
|
||||
|
||||
const BLOCKED_REQUEST_HEADERS: &[&str] = &[
|
||||
"accept-encoding",
|
||||
"authorization",
|
||||
"connection",
|
||||
"cookie",
|
||||
@@ -118,14 +118,12 @@ pub async fn proxy_assets(
|
||||
if BLOCKED_RESPONSE_HEADERS.contains(&name_str) {
|
||||
continue;
|
||||
}
|
||||
if name_str == "content-encoding" || name_str == "content-length" {
|
||||
continue;
|
||||
}
|
||||
response_headers.insert(name.clone(), value.clone());
|
||||
}
|
||||
set_known_asset_content_type(&mut response_headers, &path);
|
||||
set_font_cors(&mut response_headers);
|
||||
set_proxied_cache_control(&mut response_headers, &path, status);
|
||||
set_vary_on_accept_encoding(&mut response_headers);
|
||||
|
||||
let asset_csp = build_asset_csp(
|
||||
&state.config.csp,
|
||||
@@ -169,7 +167,10 @@ pub(super) async fn serve_local_asset(
|
||||
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
|
||||
.ok()
|
||||
.and_then(|metadata| local_asset_entity_tag(&metadata));
|
||||
@@ -182,7 +183,8 @@ pub(super) async fn serve_local_asset(
|
||||
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,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
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) {
|
||||
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());
|
||||
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>) {
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static(asset_cache_control(relative_path)),
|
||||
);
|
||||
set_vary_on_accept_encoding(headers);
|
||||
if is_font_mime(guess_mime(relative_path)) {
|
||||
headers.insert(
|
||||
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) {
|
||||
let is_font = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
@@ -290,12 +373,14 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::AppProxyConfig;
|
||||
use crate::discovery_cache::DiscoveryCache;
|
||||
use crate::state::build_http_client;
|
||||
use axum::Router;
|
||||
use axum::http::Request as HttpRequest;
|
||||
use axum::http::header::HeaderName;
|
||||
use fluxer_common::config::GeoipSourceConfig;
|
||||
use fluxer_common::geoip::{GeoipConfig, GeoipResolver};
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn spawn_upstream(status: StatusCode, cache_control: &'static str) -> String {
|
||||
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 {
|
||||
let mut config = AppProxyConfig::from_env();
|
||||
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 {
|
||||
config: Arc::new(config),
|
||||
http_client: reqwest::Client::new(),
|
||||
http_client: build_http_client().unwrap(),
|
||||
discovery_cache: Arc::new(DiscoveryCache::new()),
|
||||
geoip: Arc::new(GeoipResolver::from_config(&GeoipConfig {
|
||||
geoip_source: GeoipSourceConfig::Filesystem {
|
||||
@@ -543,6 +639,11 @@ mod tests {
|
||||
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 {
|
||||
self.root.to_str().unwrap()
|
||||
}
|
||||
@@ -719,6 +820,365 @@ mod tests {
|
||||
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]
|
||||
fn known_js_asset_overrides_upstream_octet_stream() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::discovery_cache::DiscoveryCache;
|
||||
use crate::invite_meta::InviteMetaResolver;
|
||||
use fluxer_common::geoip::GeoipResolver;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
@@ -15,3 +16,14 @@ pub struct AppState {
|
||||
pub invite_meta: Option<Arc<InviteMetaResolver>>,
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user