Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

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

未关闭
#26 0 条评论 0 个 reaction 已指派 1 人 在 GitHub 查看

@aRustyDev 已经在做这个了。

开始于 2026年1月5日。

评估

这个 Issue 还没有评估数据。

描述

documentation

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:

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

主要语言
Rust
星标
0
派生
1
PR 合并指标
30 天内没有已合并 PR

环境准备

  • 没有 Dockerfile 或 Docker Compose 文件
  • 没有 Pull Request 模板
  • 阅读贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

aRustyDev/mdbook-htmx 的其他 Issue

查看 aRustyDev/mdbook-htmx 的全部 Issue

相似的 Issue

更多 Rust Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。