haematite-cli 0.21.0

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
//! 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;