docs(adr): ADR-0018: Asset Hashing and Cache Busting
@aRustyDev ci sta già lavorando.
Dal 5/1/2026.
Valutazione
Questa issue non è ancora stata valutata.
Descrizione
ADR-0018: Asset Hashing and Cache Busting
Status
Accepted
Context
Static assets (CSS, JS, images) should be cached aggressively by browsers and CDNs for performance. However, when assets change, users must receive the updated versions immediately.
The standard solution is cache busting via content hashing: include a hash of the file's content in its filename, so changed files get new URLs.
Decision Drivers
- Long Cache TTLs - Assets should be cached for months/years
- Instant Updates - Changed assets must not be stale
- Build Performance - Hashing shouldn't slow builds significantly
- Template Integration - Templates need to reference hashed filenames
- CDN Compatibility - Work with Cloudflare, S3, etc.
Decision
Use content-based hashing with an asset manifest for template resolution.
Hashing Strategy
Original: assets/css/main.css
Hashed: assets/css/main.a1b2c3d4.css
Hash properties:
- Algorithm: xxHash (fast, good distribution)
- Length: 8 characters (hex)
- Position: Before extension (preserves MIME type inference)
Asset Manifest
Generate asset-manifest.json for template lookups:
{
"version": "1.0.0",
"generated": "2026-01-04T12:00:00Z",
"assets": {
"css/main.css": "css/main.a1b2c3d4.css",
"css/theme-dark.css": "css/theme-dark.e5f6g7h8.css",
"js/htmx.min.js": "js/htmx.min.i9j0k1l2.js",
"images/logo.svg": "images/logo.m3n4o5p6.svg"
},
"integrity": {
"css/main.a1b2c3d4.css": "sha384-abc123...",
"js/htmx.min.i9j0k1l2.js": "sha384-def456..."
}
}
Template Integration
Custom Tera function for asset resolution:
fn make_asset_fn(manifest: AssetManifest) -> impl Function {
Box::new(move |args: &HashMap<String, Value>| -> tera::Result<Value> {
let path = args.get("path")
.and_then(|v| v.as_str())
.ok_or("asset() requires 'path' argument")?;
let hashed = manifest.resolve(path)
.unwrap_or_else(|| path.to_string());
Ok(to_value(format!("/assets/{}", hashed))?)
})
}
Template usage:
<!-- Template -->
<link rel="stylesheet" href="{{ asset(path='css/main.css') }}">
<script src="{{ asset(path='js/htmx.min.js') }}"></script>
<!-- Output -->
<link rel="stylesheet" href="/assets/css/main.a1b2c3d4.css">
<script src="/assets/js/htmx.min.i9j0k1l2.js"></script>
Subresource Integrity (SRI)
For security-critical assets:
<script
src="{{ asset(path='js/htmx.min.js') }}"
integrity="{{ integrity(path='js/htmx.min.js') }}"
crossorigin="anonymous"
></script>
fn make_integrity_fn(manifest: AssetManifest) -> impl Function {
Box::new(move |args: &HashMap<String, Value>| -> tera::Result<Value> {
let path = args.get("path")
.and_then(|v| v.as_str())
.ok_or("integrity() requires 'path' argument")?;
let hash = manifest.get_integrity(path)
.ok_or_else(|| format!("No integrity hash for {}", path))?;
Ok(to_value(hash)?)
})
}
Implementation
Asset Hasher
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use xxhash_rust::xxh3::xxh3_64;
pub struct AssetHasher {
assets: HashMap<String, HashedAsset>,
}
pub struct HashedAsset {
pub original: String,
pub hashed: String,
pub hash: String,
pub integrity: String,
}
impl AssetHasher {
pub fn new() -> Self {
Self { assets: HashMap::new() }
}
pub fn hash_file(&mut self, path: &Path) -> Result<String> {
let content = fs::read(path)?;
// Content hash (xxHash for speed)
let hash = format!("{:08x}", xxh3_64(&content));
// Integrity hash (SHA-384 for SRI)
use sha2::{Sha384, Digest};
let integrity = base64::encode(Sha384::digest(&content));
let integrity_sri = format!("sha384-{}", integrity);
// Build hashed filename
let original = path.file_name().unwrap().to_string_lossy();
let stem = path.file_stem().unwrap().to_string_lossy();
let ext = path.extension().map(|e| e.to_string_lossy()).unwrap_or_default();
let hashed = if ext.is_empty() {
format!("{}.{}", stem, hash)
} else {
format!("{}.{}.{}", stem, hash, ext)
};
let asset = HashedAsset {
original: original.to_string(),
hashed: hashed.clone(),
hash,
integrity: integrity_sri,
};
self.assets.insert(original.to_string(), asset);
Ok(hashed)
}
pub fn write_manifest(&self, path: &Path) -> Result<()> {
let manifest = AssetManifest {
version: "1.0.0".to_string(),
generated: chrono::Utc::now().to_rfc3339(),
assets: self.assets.iter()
.map(|(k, v)| (k.clone(), v.hashed.clone()))
.collect(),
integrity: self.assets.iter()
.map(|(_, v)| (v.hashed.clone(), v.integrity.clone()))
.collect(),
};
let json = serde_json::to_string_pretty(&manifest)?;
fs::write(path, json)?;
Ok(())
}
}
File Processing Pipeline
pub fn process_assets(src: &Path, dest: &Path, config: &AssetsConfig) -> Result<AssetManifest> {
let mut hasher = AssetHasher::new();
for entry in walkdir::WalkDir::new(src) {
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
let rel_path = entry.path().strip_prefix(src)?;
if config.hash_files && should_hash(entry.path()) {
// Hash and copy with new name
let hashed_name = hasher.hash_file(entry.path())?;
let dest_path = dest.join(rel_path.parent().unwrap()).join(&hashed_name);
fs::create_dir_all(dest_path.parent().unwrap())?;
fs::copy(entry.path(), &dest_path)?;
} else {
// Copy as-is
let dest_path = dest.join(rel_path);
fs::create_dir_all(dest_path.parent().unwrap())?;
fs::copy(entry.path(), &dest_path)?;
}
}
hasher.write_manifest(&dest.join("asset-manifest.json"))?;
Ok(hasher.to_manifest())
}
fn should_hash(path: &Path) -> bool {
match path.extension().and_then(|e| e.to_str()) {
Some("css" | "js" | "svg" | "woff2") => true,
Some("png" | "jpg" | "gif" | "webp") => true,
_ => false,
}
}
Configuration
[output.htmx.assets]
hash-files = true # Enable content hashing
hash-algorithm = "xxhash" # xxhash | sha256
hash-length = 8 # Characters to include
generate-sri = true # Generate SRI hashes
exclude = ["fonts/*"] # Don't hash these patterns
Cache Headers
Server should set appropriate headers:
// Hashed assets: cache forever
app.use("/assets/*", (c, next) => {
if (isHashedAsset(c.req.path)) {
c.header("Cache-Control", "public, max-age=31536000, immutable");
}
return next();
});
function isHashedAsset(path: string): boolean {
// Match pattern: name.xxxxxxxx.ext
return /\.[a-f0-9]{8}\.\w+$/.test(path);
}
CDN Configuration
Cloudflare
// wrangler.toml
[vars]
ASSET_MANIFEST = "asset-manifest.json"
// Worker
const manifest = await c.env.ASSETS.get(c.env.ASSET_MANIFEST, "json");
S3 + CloudFront
{
"CacheBehaviors": [{
"PathPattern": "/assets/*",
"DefaultTTL": 31536000,
"MaxTTL": 31536000,
"Compress": true
}]
}
Incremental Hashing
For build performance, cache hashes based on file modification time:
pub struct HashCache {
entries: HashMap<PathBuf, CacheEntry>,
}
struct CacheEntry {
mtime: SystemTime,
hash: String,
}
impl HashCache {
pub fn load(path: &Path) -> Result<Self> {
// Load from .mdbook-htmx-cache/asset-hashes.json
}
pub fn get_or_compute(&mut self, path: &Path) -> Result<String> {
let mtime = fs::metadata(path)?.modified()?;
if let Some(entry) = self.entries.get(path) {
if entry.mtime == mtime {
return Ok(entry.hash.clone());
}
}
// Compute new hash
let hash = compute_hash(path)?;
self.entries.insert(path.to_owned(), CacheEntry { mtime, hash: hash.clone() });
Ok(hash)
}
}
Consequences
Positive
- Long cache TTLs improve performance
- Changed assets update immediately
- SRI provides security guarantees
- Works with any CDN
Negative
- Slightly more complex asset references
- Need to rebuild when assets change
- Manifest file adds to build output
Mitigation
- Template functions hide complexity
- Watch mode triggers on asset changes
- Manifest is small and cacheable
Alternatives Considered
Query String Versioning
Use main.css?v=123 instead of main.123.css.
Rejected because:
- Some CDNs ignore query strings
- Less reliable cache busting
- Can't use SRI with query strings
ETag-Based Caching
Rely on server ETag headers.
Rejected because:
- Still requires validation request
- Slower than immutable caching
- CDN behavior varies
Build Number in Filename
Use build number: main.build-42.css.
Rejected because:
- Changes all filenames on every build
- Wastes cache even for unchanged files
- Content hash is more precise
References
- Lingua principale
- Rust
- Stelle
- 0
- Fork
- 1
- Metriche di merge delle PR
- Nessuna PR unita negli ultimi 30g
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Nessun modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di aRustyDev/mdbook-htmx
-
feat(ci): Implement cross-repo-validate compliance for docs syncForse di nuovo libera @aRustyDev l’ha presa 285 giorni fa e non c’è nessuna pull request aperta. Apertaci enhancement
aRustyDev/mdbook-htmx#58 · 1 commento · 1 assegnatario ·
-
docs(htmx): Explain how HTMX worksForse di nuovo libera @aRustyDev l’ha presa 288 giorni fa e non c’è nessuna pull request aperta. Aperta
aRustyDev/mdbook-htmx#48 · 3 commenti · 1 assegnatario ·
-
docs(mdbook-htmx): Parent Issue for documentationForse di nuovo libera @aRustyDev l’ha presa 288 giorni fa e non c’è nessuna pull request aperta. Apertadocumentation
aRustyDev/mdbook-htmx#47 · 1 assegnatario ·
-
docs(examples): Secure Self-Hosted Meilisearch via CloudflareForse di nuovo libera @aRustyDev l’ha presa 288 giorni fa e non c’è nessuna pull request aperta. Apertadocumentation
aRustyDev/mdbook-htmx#46 · 1 assegnatario ·
-
docs(examples): Kubernetes with MeilisearchForse di nuovo libera @aRustyDev l’ha presa 288 giorni fa e non c’è nessuna pull request aperta. Apertadocumentation
aRustyDev/mdbook-htmx#45 · 1 assegnatario ·
Tutte le issue di aRustyDev/mdbook-htmx
Issue simili
-
request-device panics the host on Device(Lost), Device(OutOfMemory) and other unmapped errorsAperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
wasi-gfx/wasi-gfx-runtime#152 ·
-
`rtk ruff -- <path>` drops the leading `--`, so `rtk ruff -- check` lints the whole directoryApertaarea:cli bug good first issue priority:medium
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
rtk-ai/rtk#4702 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
aaif-goose/goose#13031 ·
I maintainer di solito rispondono entro 1 giorno
-
enhancement
Difficoltà 2/5 1-3 ore Idoneità per principianti 63/100
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
I maintainer di solito rispondono entro 1 giorno