Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ panic = "abort"
[dependencies]
anyhow = "1"
async-compression = { version = "0.4.41", features = ["gzip", "tokio"] }
brotli = "8"
async-tar = { version = "0.6", default-features = false, features = ["runtime-tokio"] }
axum = { version = "0.8", features = ["ws"] }
axum-server = "0.8"
Expand All @@ -43,6 +44,7 @@ htmlescape = "0.3.1"
http = "1.4"
humantime = "2"
humantime-serde = "1"
indicatif = "0.18"
local-ip-address = "0.6"
lol_html = "2"
mime_guess = "2"
Expand Down
8 changes: 7 additions & 1 deletion src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use tokio_stream::wrappers::ReadDirStream;

use crate::common::{BUILDING, ERROR, SUCCESS, remove_dir_all};
use crate::config::{STAGE_DIR, rt::RtcBuild, types::WsProtocol};
use crate::pipelines::HtmlPipeline;
use crate::pipelines::{HtmlPipeline, compress_dist};

pub type BuildResult = Result<()>;

Expand Down Expand Up @@ -123,6 +123,12 @@ impl BuildSystem {
let staging_dist = self.cfg.staging_dist.clone();
tracing::info!("applying new distribution");

// Pre-compress assets in the staging area so the sidecars are moved into `dist` along with
// their originals by the move step below.
compress_dist(&self.cfg)
.await
.context("error compressing distribution assets")?;

// Build succeeded, so delete everything in `dist`, move everything
// from `dist/.stage` to `dist`, and then delete `dist/.stage`.
self.clean_final().await?;
Expand Down
52 changes: 51 additions & 1 deletion src/cmd/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use crate::{
config::{
self, Configuration, Tools,
rt::{self, RtcBuild, RtcBuilder},
types::{BaseUrl, Minify},
types::{BaseUrl, CompressionAlgorithm, CompressionLevel, Minify},
},
};
use anyhow::Result;
Expand Down Expand Up @@ -116,6 +116,20 @@ pub struct Build {
#[arg(default_missing_value="true", num_args=0..=1)]
pub allow_self_closing_script: Option<bool>,

/// Pre-compress assets into sidecar files (e.g. `index.html.br`) served via `Accept-Encoding`.
///
/// Comma-separated list of algorithms; supported: `gzip`, `brotli`. Overrides the config file.
#[arg(long, value_delimiter = ',', env = "TRUNK_BUILD_COMPRESSION")]
pub compression: Option<Vec<CompressionAlgorithm>>,

/// Compression effort: `low` (fastest), `medium` (default), or `high` (smallest, slowest).
#[arg(long, env = "TRUNK_BUILD_COMPRESSION_LEVEL")]
pub compression_level: Option<CompressionLevel>,

/// Skip compressing files smaller than this size, in bytes.
#[arg(long, env = "TRUNK_BUILD_COMPRESSION_MIN_SIZE")]
pub compression_min_size: Option<u64>,

// NOTE: flattened structures come last
#[command(flatten)]
pub core: super::core::Core,
Expand Down Expand Up @@ -149,6 +163,9 @@ impl Build {
minify,
no_sri,
allow_self_closing_script,
compression,
compression_level,
compression_min_size,
tools,
} = self;

Expand Down Expand Up @@ -184,6 +201,12 @@ impl Build {
config.build.no_sri = no_sri.unwrap_or(config.build.no_sri);
config.build.allow_self_closing_script =
allow_self_closing_script.unwrap_or(config.build.allow_self_closing_script);
config.build.compression.algorithms =
compression.unwrap_or(config.build.compression.algorithms);
config.build.compression.level =
compression_level.unwrap_or(config.build.compression.level);
config.build.compression.min_size =
compression_min_size.unwrap_or(config.build.compression.min_size);

let config = core.apply_to(config)?;
let config = tools.apply_to(config)?;
Expand Down Expand Up @@ -212,6 +235,7 @@ impl Build {

#[cfg(test)]
mod test {
use crate::config::types::CompressionAlgorithm;
use crate::{Trunk, TrunkSubcommands};
use clap::Parser;
use rstest::rstest;
Expand All @@ -229,4 +253,30 @@ mod test {

assert_eq!(build.no_default_features, expected);
}

#[rstest]
#[case(&["trunk", "build"], None)]
#[case(&["trunk", "build", "--compression", "gzip"], Some(vec![CompressionAlgorithm::Gzip]))]
#[case(&["trunk", "build", "--compression", "gzip,brotli"], Some(vec![CompressionAlgorithm::Gzip, CompressionAlgorithm::Brotli]))]
fn test_compression_arg(
#[case] input: &[&str],
#[case] expected: Option<Vec<CompressionAlgorithm>>,
) {
let cli = Trunk::parse_from(input);
let TrunkSubcommands::Build(build) = cli.action else {
panic!("must be a build command");
};

assert_eq!(build.compression, expected);
}

#[test]
fn test_compression_min_size_arg() {
let cli = Trunk::parse_from(["trunk", "build", "--compression-min-size", "2048"]);
let TrunkSubcommands::Build(build) = cli.action else {
panic!("must be a build command");
};

assert_eq!(build.compression_min_size, Some(2048));
}
}
67 changes: 66 additions & 1 deletion src/config/models/build.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::config::{
models::ConfigModel,
types::{BaseUrl, Minify},
types::{BaseUrl, CompressionAlgorithm, CompressionLevel, Minify},
};
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize, de};
Expand Down Expand Up @@ -160,8 +160,64 @@ pub struct Build {
/// The placeholder which is used in the 'nonce' attribute.
#[serde(default = "default::nonce_placeholder")]
pub nonce_placeholder: String,

/// Optional pre-compression of build assets into sidecar files.
#[serde(default)]
pub compression: Compression,
}

/// Config options for pre-compressing build assets into sidecar files (e.g. `index.html.gz`).
///
/// This is disabled by default; set `algorithms` to enable it.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
pub struct Compression {
/// The compression algorithms to apply. For each enabled algorithm a sidecar file is written
/// next to the original asset (e.g. `app.js.gz`, `app.js.br`).
///
/// Leaving this empty (the default) disables compression entirely.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub algorithms: Vec<CompressionAlgorithm>,

/// How much effort to spend compressing: `low`, `medium` (default), or `high`.
#[serde(default)]
pub level: CompressionLevel,

/// Skip files smaller than this size, in bytes. Compressing tiny files rarely pays off.
#[serde(default = "default::compression_min_size")]
pub min_size: u64,

/// Only keep a compressed sidecar if its size is at most this percentage of the original size.
///
/// For example, `90` keeps the sidecar only when it saves at least 10%. A value of `100` keeps
/// any sidecar that is not larger than the original.
#[serde(default = "default::compression_min_ratio_percent")]
pub min_ratio_percent: u8,

/// Glob patterns (relative to the dist dir) of files to include. When empty, all files are
/// considered (subject to `exclude`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include: Vec<String>,

/// Glob patterns (relative to the dist dir) of files to exclude. Applied after `include`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude: Vec<String>,
}

impl Default for Compression {
fn default() -> Self {
Self {
algorithms: Vec::new(),
level: CompressionLevel::default(),
min_size: default::compression_min_size(),
min_ratio_percent: default::compression_min_ratio_percent(),
include: Vec::new(),
exclude: Vec::new(),
}
}
}

impl ConfigModel for Compression {}

fn string_or_vec<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
where
T: Deserialize<'de> + FromStr,
Expand Down Expand Up @@ -233,6 +289,7 @@ impl Default for Build {
allow_self_closing_script: false,
create_nonce: false,
nonce_placeholder: default::nonce_placeholder(),
compression: Default::default(),
}
}
}
Expand Down Expand Up @@ -264,6 +321,14 @@ mod default {
pub fn nonce_placeholder() -> String {
"{{__TRUNK NONCE__}}".to_string()
}

pub const fn compression_min_size() -> u64 {
1024
}

pub const fn compression_min_ratio_percent() -> u8 {
90
}
}

mod schema {
Expand Down
9 changes: 9 additions & 0 deletions src/config/models/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ pub struct Serve {
/// The CSP; {{NONE}} is replaced by a random nonce
#[serde(default = "default::csp")]
pub csp: Vec<String>,
/// Serve precompressed sidecar files (e.g. `index.html.br`) based on the request's
/// `Accept-Encoding` header.
///
/// When unset, this follows the build's compression configuration: the algorithms produced at
/// build time are served. Set to `true` to serve any available `.gz`/`.br` sidecar, or `false`
/// to disable serving precompressed files entirely.
#[serde(default)]
pub precompressed: Option<bool>,
}

impl Default for Serve {
Expand Down Expand Up @@ -118,6 +126,7 @@ impl Default for Serve {
proxy_no_redirect: None,
disable_csp: false,
csp: default::csp(),
precompressed: None,
}
}
}
Expand Down
33 changes: 33 additions & 0 deletions src/config/models/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,36 @@ async fn example_config() {
.await
.expect("example config should be parsable");
}

#[test]
fn parse_compression_config() {
use crate::config::types::CompressionAlgorithm;

let toml = r#"
[build.compression]
algorithms = ["gzip", "brotli"]
min_size = 2048
min_ratio_percent = 80
include = ["*.js"]
exclude = ["*.png"]
"#;
let cfg: Configuration = toml::from_str(toml).expect("config should parse");
let compression = cfg.build.compression;
assert_eq!(
compression.algorithms,
vec![CompressionAlgorithm::Gzip, CompressionAlgorithm::Brotli]
);
assert_eq!(compression.min_size, 2048);
assert_eq!(compression.min_ratio_percent, 80);
assert_eq!(compression.include, vec!["*.js".to_string()]);
assert_eq!(compression.exclude, vec!["*.png".to_string()]);
}

#[test]
fn compression_disabled_by_default() {
let cfg: Configuration = toml::from_str("").expect("empty config should parse");
assert!(
cfg.build.compression.algorithms.is_empty(),
"compression should be disabled by default"
);
}
Loading