app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit b703354ca93644a5bc9117282018f74b41088ec0
parent b627b7c43a137758d2cc9ebcecc5b8588d9afc3d
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 07:25:02 +0000

app-utils: add asset cache helpers

- add asset cache config and modes
- add cache-aware fetch helpers
- export cache helpers from utils crate
- add unit tests for cache helpers

Diffstat:
MCargo.lock | 1+
MCargo.toml | 8++++++++
Mcrates/utils/Cargo.toml | 1+
Acrates/utils/src/cache/mod.rs | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/utils/src/lib.rs | 5+++++
5 files changed, 235 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1586,6 +1586,7 @@ dependencies = [ "serde_json", "uuid", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] diff --git a/Cargo.toml b/Cargo.toml @@ -48,6 +48,9 @@ web-sys = { version = "0.3.77", features = [ "IdbTransaction", "IdbTransactionMode", "Coordinates", + "Cache", + "CacheStorage", + "Headers", "Geolocation", "Navigator", "Notification", @@ -61,6 +64,11 @@ web-sys = { version = "0.3.77", features = [ "Position", "PositionError", "PositionOptions", + "Request", + "RequestCache", + "RequestInit", + "Response", + "ResponseType", ] } wasm-bindgen-futures = "0.4" base64 = "0.22" diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml @@ -23,6 +23,7 @@ once_cell = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] gloo-timers = { workspace = true } wasm-bindgen = { workspace = true } +wasm-bindgen-futures = { workspace = true } [dev-dependencies] futures = { workspace = true } diff --git a/crates/utils/src/cache/mod.rs b/crates/utils/src/cache/mod.rs @@ -0,0 +1,220 @@ +#![forbid(unsafe_code)] + +use crate::error::RadrootsAppUtilsError; + +pub const RADROOTS_ASSET_CACHE_NAME: &str = "cache-app-assets-v1"; +pub const RADROOTS_ASSET_CACHE_PREFIX: &str = "cache-app-assets-v"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AssetCacheMode { + Default, + NoStore, + Reload, + NoCache, + ForceCache, + OnlyIfCached, +} + +#[derive(Debug, Clone)] +pub struct AssetCacheRequestInit { + pub cache: Option<AssetCacheMode>, +} + +#[derive(Debug, Clone)] +pub struct AssetCacheFetchConfig { + pub cache_name: Option<String>, + pub request_init: Option<AssetCacheRequestInit>, +} + +#[cfg(target_arch = "wasm32")] +pub type AssetResponse = web_sys::Response; + +#[cfg(not(target_arch = "wasm32"))] +pub type AssetResponse = (); + +pub type AssetBytes = Vec<u8>; + +pub async fn asset_cache_fetch( + url: &str, + config: Option<&AssetCacheFetchConfig>, +) -> Result<AssetResponse, RadrootsAppUtilsError> { + asset_cache_fetch_impl(url, config).await +} + +pub async fn asset_cache_fetch_bytes( + url: &str, + config: Option<&AssetCacheFetchConfig>, +) -> Result<Option<AssetBytes>, RadrootsAppUtilsError> { + #[cfg(target_arch = "wasm32")] + { + let response = asset_cache_fetch(url, config).await?; + if !response.ok() { + return Ok(None); + } + let buffer = wasm_bindgen_futures::JsFuture::from(response.array_buffer()) + .await + .map_err(|_| RadrootsAppUtilsError::Unavailable)?; + let array = js_sys::Uint8Array::new(&buffer); + let mut bytes = vec![0u8; array.length() as usize]; + array.copy_to(&mut bytes); + Ok(Some(bytes)) + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = url; + let _ = config; + Err(RadrootsAppUtilsError::Unavailable) + } +} + +#[cfg(any(test, target_arch = "wasm32"))] +fn cache_name_resolve(config: Option<&AssetCacheFetchConfig>) -> String { + config + .and_then(|config| config.cache_name.as_ref().cloned()) + .unwrap_or_else(|| RADROOTS_ASSET_CACHE_NAME.to_string()) +} + +#[cfg(any(test, target_arch = "wasm32"))] +fn cache_key_resolve(url: &str) -> String { + url.split('#').next().unwrap_or(url).to_string() +} + +#[cfg(target_arch = "wasm32")] +async fn asset_cache_fetch_impl( + url: &str, + config: Option<&AssetCacheFetchConfig>, +) -> Result<AssetResponse, RadrootsAppUtilsError> { + use wasm_bindgen::JsCast; + + let cache_name = cache_name_resolve(config); + let cache_key = cache_key_resolve(url); + if let Some(cached) = cache_read(&cache_name, &cache_key).await? { + return Ok(cached); + } + let response = fetch_with_init(url, config).await?; + if response.ok() || response.type_() == web_sys::ResponseType::Opaque { + cache_write(&cache_name, &cache_key, response.clone()).await?; + } + Ok(response) +} + +#[cfg(not(target_arch = "wasm32"))] +async fn asset_cache_fetch_impl( + _url: &str, + _config: Option<&AssetCacheFetchConfig>, +) -> Result<AssetResponse, RadrootsAppUtilsError> { + Err(RadrootsAppUtilsError::Unavailable) +} + +#[cfg(target_arch = "wasm32")] +async fn fetch_with_init( + url: &str, + config: Option<&AssetCacheFetchConfig>, +) -> Result<AssetResponse, RadrootsAppUtilsError> { + use wasm_bindgen::JsCast; + + let window = web_sys::window().ok_or(RadrootsAppUtilsError::Unavailable)?; + let mut init = web_sys::RequestInit::new(); + if let Some(request_init) = config.and_then(|config| config.request_init.as_ref()) { + if let Some(cache_mode) = request_init.cache { + init.cache(cache_mode.to_request_cache()); + } + } + let response = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str_and_init(url, &init)) + .await + .map_err(|_| RadrootsAppUtilsError::Unavailable)?; + response + .dyn_into::<web_sys::Response>() + .map_err(|_| RadrootsAppUtilsError::Unavailable) +} + +#[cfg(target_arch = "wasm32")] +async fn cache_read( + cache_name: &str, + cache_key: &str, +) -> Result<Option<AssetResponse>, RadrootsAppUtilsError> { + use wasm_bindgen::JsCast; + + let window = web_sys::window().ok_or(RadrootsAppUtilsError::Unavailable)?; + let storage = window + .caches() + .map_err(|_| RadrootsAppUtilsError::Unavailable)?; + let cache = wasm_bindgen_futures::JsFuture::from(storage.open(cache_name)) + .await + .map_err(|_| RadrootsAppUtilsError::Unavailable)?; + let cache = cache + .dyn_into::<web_sys::Cache>() + .map_err(|_| RadrootsAppUtilsError::Unavailable)?; + let cached = wasm_bindgen_futures::JsFuture::from(cache.match_with_str(cache_key)) + .await + .map_err(|_| RadrootsAppUtilsError::Unavailable)?; + if cached.is_undefined() || cached.is_null() { + return Ok(None); + } + let response = cached + .dyn_into::<web_sys::Response>() + .map_err(|_| RadrootsAppUtilsError::Unavailable)?; + Ok(Some(response)) +} + +#[cfg(target_arch = "wasm32")] +async fn cache_write( + cache_name: &str, + cache_key: &str, + response: AssetResponse, +) -> Result<(), RadrootsAppUtilsError> { + use wasm_bindgen::JsCast; + + let window = web_sys::window().ok_or(RadrootsAppUtilsError::Unavailable)?; + let storage = window + .caches() + .map_err(|_| RadrootsAppUtilsError::Unavailable)?; + let cache = wasm_bindgen_futures::JsFuture::from(storage.open(cache_name)) + .await + .map_err(|_| RadrootsAppUtilsError::Unavailable)?; + let cache = cache + .dyn_into::<web_sys::Cache>() + .map_err(|_| RadrootsAppUtilsError::Unavailable)?; + wasm_bindgen_futures::JsFuture::from(cache.put_with_str(cache_key, &response)) + .await + .map_err(|_| RadrootsAppUtilsError::Unavailable)?; + Ok(()) +} + +impl AssetCacheMode { + #[cfg(target_arch = "wasm32")] + fn to_request_cache(self) -> web_sys::RequestCache { + match self { + AssetCacheMode::Default => web_sys::RequestCache::Default, + AssetCacheMode::NoStore => web_sys::RequestCache::NoStore, + AssetCacheMode::Reload => web_sys::RequestCache::Reload, + AssetCacheMode::NoCache => web_sys::RequestCache::NoCache, + AssetCacheMode::ForceCache => web_sys::RequestCache::ForceCache, + AssetCacheMode::OnlyIfCached => web_sys::RequestCache::OnlyIfCached, + } + } +} + +#[cfg(test)] +mod tests { + use super::{cache_key_resolve, cache_name_resolve, AssetCacheFetchConfig, RADROOTS_ASSET_CACHE_NAME}; + + #[test] + fn cache_name_defaults() { + assert_eq!(cache_name_resolve(None), RADROOTS_ASSET_CACHE_NAME); + } + + #[test] + fn cache_name_uses_config() { + let config = AssetCacheFetchConfig { + cache_name: Some("custom".to_string()), + request_init: None, + }; + assert_eq!(cache_name_resolve(Some(&config)), "custom"); + } + + #[test] + fn cache_key_strips_hash() { + assert_eq!(cache_key_resolve("path#hash"), "path"); + } +} diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs @@ -4,6 +4,7 @@ pub mod error; pub mod errors; pub mod r#async; pub mod binary; +pub mod cache; pub mod currency; pub mod id; pub mod media; @@ -18,6 +19,10 @@ pub mod validation; pub use r#async::exe_iter; pub use binary::{as_array_buffer, RadrootsAppArrayBuffer}; +pub use cache::{ + asset_cache_fetch, asset_cache_fetch_bytes, AssetBytes, AssetCacheFetchConfig, AssetCacheMode, + AssetCacheRequestInit, AssetResponse, RADROOTS_ASSET_CACHE_NAME, RADROOTS_ASSET_CACHE_PREFIX, +}; pub use currency::{ fmt_price, parse_currency, parse_currency_marker, price_to_formatted, FiatCurrency, FIAT_CURRENCIES,