Skip to content

Commit 761fc86

Browse files
CopilotSeriousBugclaude
authored
Add zstd compression support (#18)
* Initial plan * Add zstd compression support Co-authored-by: SeriousBug <1008124+SeriousBug@users.noreply.github.com> * Add test coverage for DynamicFile compression methods and fix clippy warning Co-authored-by: SeriousBug <1008124+SeriousBug@users.noreply.github.com> * Fix clippy warnings and add explicit test coverage for DynamicFile compression methods Co-authored-by: SeriousBug <1008124+SeriousBug@users.noreply.github.com> * Fix dynamic tests to handle both always-embed and debug modes correctly Co-authored-by: SeriousBug <1008124+SeriousBug@users.noreply.github.com> * Format code with cargo fmt Co-authored-by: SeriousBug <1008124+SeriousBug@users.noreply.github.com> * Make zstd compression an optional feature Co-authored-by: SeriousBug <1008124+SeriousBug@users.noreply.github.com> * Add comprehensive tests for optional zstd feature Co-authored-by: SeriousBug <1008124+SeriousBug@users.noreply.github.com> * Make compression-zstd opt-in and add documentation Changes: - Remove compression-zstd from default features (requires C bindings) - Add comprehensive documentation for zstd compression feature - Document compression functions and config methods - Reorganize README features section for clarity The zstd feature is now opt-in due to its C library dependency, which may not be compatible with all build environments. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: SeriousBug <1008124+SeriousBug@users.noreply.github.com> Co-authored-by: Kaan Barmore-Genc <kaan@bgenc.dev> Co-authored-by: Claude <noreply@anthropic.com>
1 parent f64892d commit 761fc86

19 files changed

Lines changed: 440 additions & 12 deletions

Cargo.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ rust-embed-for-web-utils = { version = "11.2.1", path = "utils" }
1919
chrono = { version = "0.4", default-features = false }
2020
flate2 = "1.0"
2121
brotli = "6.0"
22+
zstd = "0.13"
2223
actix-web = "4.4"
2324

2425
[features]
@@ -32,20 +33,31 @@ include-exclude = [
3233
"rust-embed-for-web-impl/include-exclude",
3334
"rust-embed-for-web-utils/include-exclude",
3435
]
36+
compression-zstd = ["rust-embed-for-web-impl/compression-zstd", "rust-embed-for-web-utils/compression-zstd"]
3537

3638
[workspace]
3739
members = ["impl", "utils"]
3840

3941
[[test]]
4042
name = "compression"
4143
path = "tests/compression.rs"
44+
required-features = ["always-embed", "compression-zstd"]
45+
46+
[[test]]
47+
name = "compression_without_zstd"
48+
path = "tests/compression_without_zstd.rs"
4249
required-features = ["always-embed"]
4350

4451
[[test]]
4552
name = "gzip"
4653
path = "tests/gzip.rs"
4754
required-features = ["always-embed"]
4855

56+
[[test]]
57+
name = "zstd"
58+
path = "tests/zstd.rs"
59+
required-features = ["always-embed", "compression-zstd"]
60+
4961
[[test]]
5062
name = "include-exclude"
5163
path = "tests/include-exclude.rs"

README.md

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ executable in exchange for better performance at runtime. In particular:
2424
or decompress anything at runtime.
2525
- If the compression makes little difference, for example a jpeg file won't
2626
compress much further if at all, then the compressed version is not included.
27-
- You can also disable this behavior by adding an attribute `#[gzip = false]` and `#[br = false]`
27+
- You can also disable this behavior by adding an attribute `#[gzip = false]`, `#[br = false]`, or `#[zstd = false]`
2828
When disabled, the compressed files won't be included for that embed.
2929
- Some metadata that is useful for web headers like `ETag` and `Last-Modified`
3030
are computed ahead of time and embedded into the executable. This makes it
@@ -68,17 +68,19 @@ The path for the `folder` is resolved relative to where `Cargo.toml` is.
6868

6969
### Disabling compression
7070

71-
You can add `#[gzip = false]` and/or `#[br = false]` attributes to your embed to
72-
disable gzip and brotli compression for the files in that embed.
71+
You can add `#[gzip = false]`, `#[br = false]`, and/or `#[zstd = false]` attributes to your embed to
72+
disable gzip, brotli, and/or zstd compression for the files in that embed.
7373
`rust-embed-for-web` will only include compressed files where the compression
7474
actually makes files smaller so files that won't compress well like images or
7575
archives already don't include their compressed versions. However you can
7676

7777
## Features
7878

79-
Both of the following features are enabled by default.
79+
### Default Features
8080

81-
### `interpolate-folder-path`
81+
The following features are enabled by default.
82+
83+
#### `interpolate-folder-path`
8284

8385
Allow environment variables and `~`s to be used in the `folder` path. Example:
8486

@@ -91,7 +93,7 @@ struct Asset;
9193
`~` will expand into your home folder, and `${PROJECT_NAME}` will expand into
9294
the value of the `PROJECT_NAME` environment variable.
9395

94-
### `include-exclude`
96+
#### `include-exclude`
9597

9698
You can filter which files are embedded by adding one or more `#[include = "*.txt"]` and `#[exclude = "*.jpg"]` attributes.
9799
Matching is done on relative file paths --the paths you use for the `.get` call-- via [`globset`](https://docs.rs/globset/latest/globset/).
@@ -111,7 +113,26 @@ For example, if you wanted to exclude all `.svg` files except for one named
111113
struct Assets;
112114
```
113115

114-
### `prefix`
116+
### Optional Features
117+
118+
#### `compression-zstd`
119+
120+
Enables zstd compression support for embedded files. When enabled, files will be compressed with zstd (in addition to gzip and brotli), allowing you to serve zstd-compressed content to clients that support it.
121+
122+
**Note:** This feature is **not enabled by default** because the `zstd` crate uses C bindings, which may not be compatible with all build environments.
123+
124+
To enable zstd compression, add this to your `Cargo.toml`:
125+
126+
```toml
127+
[dependencies]
128+
rust-embed-for-web = { version = "11.2.1", features = ["compression-zstd"] }
129+
```
130+
131+
You can also disable zstd compression for specific embeds using the `#[zstd = false]` attribute as described in the "Disabling compression" section above.
132+
133+
### Other Configuration
134+
135+
#### `prefix`
115136

116137
You can specify a prefix, which will be added to the path of the files. For example:
117138

impl/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ walkdir = "2.4.0"
2727
# Compression
2828
flate2 = "1.0"
2929
brotli = "6.0"
30+
zstd = { version = "0.13", optional = true }
3031

3132
globset = { version = "0.4", optional = true }
3233

@@ -39,3 +40,4 @@ default = []
3940
interpolate-folder-path = ["shellexpand"]
4041
include-exclude = ["rust-embed-for-web-utils/include-exclude", "globset"]
4142
always-embed = []
43+
compression-zstd = ["zstd", "rust-embed-for-web-utils/compression-zstd"]

impl/src/attributes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pub(crate) fn read_attribute_config(ast: &syn::DeriveInput) -> Config {
4242
"exclude" => parse_str(attribute).map(|v| config.add_exclude(v)),
4343
"gzip" => parse_bool(attribute).map(|v| config.set_gzip(v)),
4444
"br" => parse_bool(attribute).map(|v| config.set_br(v)),
45+
"zstd" => parse_bool(attribute).map(|v| config.set_zstd(v)),
4546
_ => None,
4647
};
4748
}

impl/src/compress.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ use std::io::{BufReader, Write};
22

33
use brotli::enc::BrotliEncoderParams;
44
use flate2::{write::GzEncoder, Compression};
5+
#[cfg(feature = "compression-zstd")]
6+
use zstd::stream::write::Encoder as ZstdEncoder;
57

68
/// Only include the compressed version if it is at least this much smaller than
79
/// the uncompressed version.
@@ -39,3 +41,31 @@ pub(crate) fn compress_br(data: &[u8]) -> Option<Vec<u8>> {
3941
None
4042
}
4143
}
44+
45+
/// Compresses data using zstd compression.
46+
///
47+
/// Returns the compressed data if it's smaller than the threshold, `None` otherwise.
48+
/// Uses compression level 3 as a balance between compression ratio and speed.
49+
#[cfg(feature = "compression-zstd")]
50+
pub(crate) fn compress_zstd(data: &[u8]) -> Option<Vec<u8>> {
51+
let mut data_zstd: Vec<u8> = Vec::new();
52+
// Level 3 provides good compression with reasonable speed for build-time compression
53+
let mut encoder = ZstdEncoder::new(&mut data_zstd, 3).expect("Failed to create zstd encoder");
54+
encoder
55+
.write_all(data)
56+
.expect("Failed to compress zstd data");
57+
encoder
58+
.finish()
59+
.expect("Failed to finish compression of zstd data");
60+
61+
if data_zstd.len() < ((data.len() as f64) * COMPRESSION_INCLUDE_THRESHOLD) as usize {
62+
Some(data_zstd)
63+
} else {
64+
None
65+
}
66+
}
67+
68+
#[cfg(not(feature = "compression-zstd"))]
69+
pub(crate) fn compress_zstd(_data: &[u8]) -> Option<Vec<u8>> {
70+
None
71+
}

impl/src/embed.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use proc_macro2::TokenStream as TokenStream2;
22
use rust_embed_for_web_utils::{get_files, Config, DynamicFile, EmbedableFile, FileEntry};
33

4-
use crate::compress::{compress_br, compress_gzip};
4+
use crate::compress::{compress_br, compress_gzip, compress_zstd};
55

66
/// Anything that can be embedded into the program.
77
///
@@ -70,6 +70,11 @@ impl<'t> MakeEmbed for EmbedDynamicFile<'t> {
7070
} else {
7171
None::<Vec<u8>>.make_embed()
7272
};
73+
let data_zstd = if self.config.should_zstd() {
74+
compress_zstd(&data).make_embed()
75+
} else {
76+
None::<Vec<u8>>.make_embed()
77+
};
7378
let data = data.make_embed();
7479
let hash = file.hash().make_embed();
7580
let etag = file.etag().make_embed();
@@ -83,6 +88,7 @@ impl<'t> MakeEmbed for EmbedDynamicFile<'t> {
8388
#data,
8489
#data_gzip,
8590
#data_br,
91+
#data_zstd,
8692
#hash,
8793
#etag,
8894
#last_modified,

impl/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,10 @@ fn impl_rust_embed_for_web(ast: &syn::DeriveInput) -> TokenStream2 {
8787
}
8888
}
8989

90-
#[proc_macro_derive(RustEmbed, attributes(folder, prefix, include, exclude, gzip, br))]
90+
#[proc_macro_derive(
91+
RustEmbed,
92+
attributes(folder, prefix, include, exclude, gzip, br, zstd)
93+
)]
9194
/// A folder that is embedded into your program.
9295
///
9396
/// For example:

tests/basic.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ fn file_name_exists() {
4141
#[test]
4242
fn readme_example() {
4343
let index = Embed::get("index.html").unwrap().data();
44+
#[allow(clippy::useless_asref)]
4445
let contents = std::str::from_utf8(index.as_ref()).unwrap();
4546
assert!(!contents.is_empty());
4647
}

tests/compression.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ struct Embed;
1111
fn html_files_are_compressed() {
1212
assert!(Embed::get("index.html").unwrap().data_gzip().is_some());
1313
assert!(Embed::get("index.html").unwrap().data_br().is_some());
14+
assert!(Embed::get("index.html").unwrap().data_zstd().is_some());
1415
}
1516

1617
#[test]
@@ -20,14 +21,18 @@ fn image_files_are_not_compressed() {
2021
.data_gzip()
2122
.is_none());
2223
assert!(Embed::get("images/flower.jpg").unwrap().data_br().is_none());
24+
assert!(Embed::get("images/flower.jpg")
25+
.unwrap()
26+
.data_zstd()
27+
.is_none());
2328
}
2429

2530
#[test]
2631
fn compression_gzip_roundtrip() {
2732
let compressed = Embed::get("index.html").unwrap().data_gzip().unwrap();
2833
let mut decompressed: Vec<u8> = Vec::new();
2934
let mut decoder = GzDecoder::new(&mut decompressed);
30-
decoder.write_all(&compressed[..]).unwrap();
35+
decoder.write_all(compressed).unwrap();
3136
decoder.finish().unwrap();
3237
let decompressed_body = String::from_utf8_lossy(&decompressed[..]);
3338
assert!(decompressed_body.starts_with("<!DOCTYPE html>"));
@@ -37,8 +42,16 @@ fn compression_gzip_roundtrip() {
3742
fn compression_br_roundtrip() {
3843
let compressed = Embed::get("index.html").unwrap().data_br().unwrap();
3944
let mut decompressed: Vec<u8> = Vec::new();
40-
let mut data_read = BufReader::new(&compressed[..]);
45+
let mut data_read = BufReader::new(compressed);
4146
brotli::BrotliDecompress(&mut data_read, &mut decompressed).unwrap();
4247
let decompressed_body = String::from_utf8_lossy(&decompressed[..]);
4348
assert!(decompressed_body.starts_with("<!DOCTYPE html>"));
4449
}
50+
51+
#[test]
52+
fn compression_zstd_roundtrip() {
53+
let compressed = Embed::get("index.html").unwrap().data_zstd().unwrap();
54+
let decompressed = zstd::bulk::decompress(compressed, 1024 * 1024).unwrap();
55+
let decompressed_body = String::from_utf8_lossy(&decompressed[..]);
56+
assert!(decompressed_body.starts_with("<!DOCTYPE html>"));
57+
}

tests/compression_without_zstd.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
use std::io::{BufReader, Write};
2+
3+
use flate2::write::GzDecoder;
4+
use rust_embed_for_web::{EmbedableFile, RustEmbed};
5+
6+
#[derive(RustEmbed)]
7+
#[folder = "examples/public"]
8+
struct Embed;
9+
10+
#[test]
11+
fn html_files_gzip_and_br_compression() {
12+
assert!(Embed::get("index.html").unwrap().data_gzip().is_some());
13+
assert!(Embed::get("index.html").unwrap().data_br().is_some());
14+
}
15+
16+
#[test]
17+
fn zstd_behavior_without_feature() {
18+
// When compression-zstd feature is not enabled, data_zstd should return None
19+
#[cfg(not(feature = "compression-zstd"))]
20+
{
21+
assert!(Embed::get("index.html").unwrap().data_zstd().is_none());
22+
}
23+
24+
// When compression-zstd feature is enabled, it may return Some or None based on effectiveness
25+
#[cfg(feature = "compression-zstd")]
26+
{
27+
// Just test that the method doesn't panic
28+
let _ = Embed::get("index.html").unwrap().data_zstd();
29+
}
30+
}
31+
32+
#[test]
33+
fn image_files_are_not_compressed() {
34+
assert!(Embed::get("images/flower.jpg")
35+
.unwrap()
36+
.data_gzip()
37+
.is_none());
38+
assert!(Embed::get("images/flower.jpg").unwrap().data_br().is_none());
39+
assert!(Embed::get("images/flower.jpg")
40+
.unwrap()
41+
.data_zstd()
42+
.is_none());
43+
}
44+
45+
#[test]
46+
fn compression_gzip_roundtrip() {
47+
let compressed = Embed::get("index.html").unwrap().data_gzip().unwrap();
48+
let mut decompressed: Vec<u8> = Vec::new();
49+
let mut decoder = GzDecoder::new(&mut decompressed);
50+
decoder.write_all(compressed).unwrap();
51+
decoder.finish().unwrap();
52+
let decompressed_body = String::from_utf8_lossy(&decompressed[..]);
53+
assert!(decompressed_body.starts_with("<!DOCTYPE html>"));
54+
}
55+
56+
#[test]
57+
fn compression_br_roundtrip() {
58+
let compressed = Embed::get("index.html").unwrap().data_br().unwrap();
59+
let mut decompressed: Vec<u8> = Vec::new();
60+
let mut data_read = BufReader::new(compressed);
61+
brotli::BrotliDecompress(&mut data_read, &mut decompressed).unwrap();
62+
let decompressed_body = String::from_utf8_lossy(&decompressed[..]);
63+
assert!(decompressed_body.starts_with("<!DOCTYPE html>"));
64+
}

0 commit comments

Comments
 (0)