1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
//! Studio's files as a browser takes them: gzip when it asks for gzip, and a
//! content-hashed asset kept by the browser for good (ACCESS-002 R2).
//!
//! A file whose name carries Vite's content hash never changes under that
//! name, so its compressed bytes are made once and kept for the process's
//! life, and the browser is told to keep it. Every other file, `index.html`
//! first, is read and compressed again on each request, so a new build is
//! served at once.
use std::collections::BTreeMap;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use flate2::Compression;
use flate2::write::GzEncoder;
/// How a content-hashed asset is cached: for a year, never revalidated.
pub const IMMUTABLE: &str = "public, max-age=31536000, immutable";
/// The length of the content hash Vite writes into an asset's name.
const HASH: usize = 8;
/// The gzipped bytes of each content-hashed asset served so far, by path.
/// Only files that exist are kept, so it holds at most one entry per asset
/// of the build being served.
#[derive(Default)]
pub struct Packed {
kept: Mutex<BTreeMap<PathBuf, Arc<[u8]>>>,
}
impl Packed {
/// The gzipped bytes of the file at `path`. A `hashed` file is
/// compressed once and answered from memory after; any other is read and
/// compressed again.
///
/// # Errors
///
/// The file's read error, or the encoder's.
pub fn gzipped(&self, path: &Path, hashed: bool) -> io::Result<Arc<[u8]>> {
if hashed {
let kept = self.lock().get(path).cloned();
if let Some(kept) = kept {
return Ok(kept);
}
}
// Compressed outside the lock, so no other request waits on it; two
// first requests at once compress it twice, to the same bytes.
let packed: Arc<[u8]> = gzip(&std::fs::read(path)?)?.into();
if hashed {
self.lock().insert(path.to_owned(), Arc::clone(&packed));
}
Ok(packed)
}
/// The map holds whole values, inserted in one step, so a panic
/// elsewhere never leaves it half written.
fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<PathBuf, Arc<[u8]>>> {
self.kept.lock().unwrap_or_else(PoisonError::into_inner)
}
}
/// `bytes` gzipped.
///
/// # Errors
///
/// The encoder's.
pub fn gzip(bytes: &[u8]) -> io::Result<Vec<u8>> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(bytes)?;
encoder.finish()
}
/// Whether an `Accept-Encoding` value takes gzip: named (or `x-gzip`) with
/// a quality above zero, or not named and `*` taken. Absent takes nothing.
#[must_use]
pub fn takes_gzip(accept: Option<&str>) -> bool {
let mut named = None;
let mut any = false;
for item in accept.unwrap_or_default().split(',') {
let mut parts = item.split(';');
let coding = parts.next().unwrap_or_default().trim();
// `q=0`, `q=0.0` and the like refuse the coding; any other quality,
// or none, takes it.
let taken = !parts
.filter_map(|part| part.trim().strip_prefix("q="))
.any(refuses);
if coding.eq_ignore_ascii_case("gzip") || coding.eq_ignore_ascii_case("x-gzip") {
named = Some(taken);
} else if coding == "*" {
any = taken;
}
}
named.unwrap_or(any)
}
/// Whether a quality refuses its coding: `0`, `0.0`, `0.000` and the like.
fn refuses(quality: &str) -> bool {
let quality = quality.trim();
!quality.is_empty() && quality.bytes().all(|byte| matches!(byte, b'0' | b'.'))
}
/// Whether `path` is a content-hashed asset, as `/assets/index-BdF3x9kZ.js`.
///
/// It is under `/assets/`, and its name ends `-` and Vite's eight-character
/// hash before its extension; the hash is drawn from letters, digits, `_` and
/// `-`.
#[must_use]
pub fn hashed(path: &str) -> bool {
let Some(name) = path.strip_prefix("/assets/") else {
return false;
};
let file = name.rsplit('/').next().unwrap_or(name);
let Some((stem, extension)) = file.rsplit_once('.') else {
return false;
};
let stem = stem.as_bytes();
let Some(cut) = stem.len().checked_sub(HASH + 1) else {
return false;
};
let (before, hash) = stem.split_at(cut);
!extension.is_empty()
&& !before.is_empty()
&& hash.first() == Some(&b'-')
&& hash
.iter()
.skip(1)
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
}
#[cfg(test)]
#[path = "packed_tests.rs"]
mod tests;