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 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
@@ -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 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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user