aRustyDev / aRustyDev/mdbook-htmx

docs(adr): ADR-0018: Asset Hashing and Cache Busting

Open
#26 0 comments 0 reactions 1 assignee Claimed by @aRustyDev View on GitHub
documentation
Dominant language
Rust
Stars
0
Forks
1
PR merge metrics
No merged PRs in 30d

Description

# 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

1. **Long Cache TTLs** - Assets should be cached for months/years
2. **Instant Updates** - Changed assets must not be stale
3. **Build Performance** - Hashing shouldn't slow builds significantly
4. **Template Integration** - Templates need to reference hashed filenames
5. **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:

```json
{
"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:

```rust
fn make_asset_fn(manifest: AssetManifest) -> impl Function {
Box::new(move |args: &HashMap| -> tera::Result {
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:

```html

```

### Subresource Integrity (SRI)

For security-critical assets:

```html

```

```rust
fn make_integrity_fn(manifest: AssetManifest) -> impl Function {
Box::new(move |args: &HashMap| -> tera::Result {
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

```rust
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use xxhash_rust::xxh3::xxh3_64;

pub struct AssetHasher {
assets: HashMap,
}

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 {
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

```rust
pub fn process_assets(src: &Path, dest: &Path, config: &AssetsConfig) -> Result {
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

```toml
[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:

```typescript
// 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

```typescript
// wrangler.toml
[vars]
ASSET_MANIFEST = "asset-manifest.json"

// Worker
const manifest = await c.env.ASSETS.get(c.env.ASSET_MANIFEST, "json");
```

#### S3 + CloudFront

```json
{
"CacheBehaviors": [{
"PathPattern": "/assets/*",
"DefaultTTL": 31536000,
"MaxTTL": 31536000,
"Compress": true
}]
}
```

## Incremental Hashing

For build performance, cache hashes based on file modification time:

```rust
pub struct HashCache {
entries: HashMap,
}

struct CacheEntry {
mtime: SystemTime,
hash: String,
}

impl HashCache {
pub fn load(path: &Path) -> Result {
// Load from .mdbook-htmx-cache/asset-hashes.json
}

pub fn get_or_compute(&mut self, path: &Path) -> Result {
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

- [HTTP Caching (MDN)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching)
- [Subresource Integrity (MDN)](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity)
- [Webpack Asset Management](https://webpack.js.org/guides/asset-management/)
- [xxHash](https://xxhash.com/)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.