mod disk;
pub use disk::{
client_asset_body, client_asset_cache_control, client_dir, client_disk_mode, client_disk_rel,
default_client_dir, read_client_disk_bytes, register_client_disk_file,
resolve_client_disk_path, set_client_dir, validate_client_disk_file,
};
use std::collections::HashMap;
use once_cell::sync::Lazy;
use parking_lot::RwLock;
use sha2::{Digest, Sha256};
use crate::View;
pub const CLIENT_SCRIPT_PREFIX: &str = "/static/client/";
static CLIENT_DIGESTS: Lazy<RwLock<HashMap<String, String>>> =
Lazy::new(|| RwLock::new(HashMap::new()));
pub fn content_digest(bytes: &[u8]) -> String {
let hash = Sha256::digest(bytes);
let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
hex[..16].to_string()
}
pub fn register_client_asset_digest(id: &str, body: &[u8]) {
let digest = content_digest(body);
CLIENT_DIGESTS.write().insert(id.to_string(), digest);
}
pub fn client_script_path(id: &str) -> String {
format!("{CLIENT_SCRIPT_PREFIX}{id}.js")
}
#[derive(Debug, Clone)]
pub struct ClientComponent {
pub id: String,
pub class: Option<String>,
pub props: Option<serde_json::Value>,
pub aria_hidden: bool,
pub lazy: bool,
}
impl ClientComponent {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
class: None,
props: None,
aria_hidden: true,
lazy: false,
}
}
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into());
self
}
pub fn props(mut self, props: impl serde::Serialize) -> Self {
match serde_json::to_value(props) {
Ok(v) => self.props = Some(v),
Err(e) => {
tracing::warn!(
client_id = %self.id,
error = %e,
"ClientComponent props failed to serialize — mount will have no data-r-client-props"
);
}
}
self
}
pub fn aria_hidden(mut self, hidden: bool) -> Self {
self.aria_hidden = hidden;
self
}
pub fn lazy(mut self, lazy: bool) -> Self {
self.lazy = lazy;
self
}
pub fn script_url(&self) -> String {
client_script_url(&self.id)
}
pub fn mount_id(&self) -> String {
format!("r-client-{}", self.id)
}
}
pub fn client_script_url(id: &str) -> String {
let memory = CLIENT_DIGESTS.read().get(id).cloned();
disk::client_script_url_disk_aware(id, memory.as_deref())
}
pub fn client_component(comp: ClientComponent) -> View {
if let Err(()) = validate_client_id(&comp.id) {
tracing::warn!(
client_id = %comp.id,
"invalid client component id — must be 1-64 alphanumeric, dash, or underscore"
);
return View::empty();
}
let mut attrs = format!(
r#"data-r-client="{}" id="{}""#,
escape_attr(&comp.id),
escape_attr(&comp.mount_id()),
);
if let Some(class) = &comp.class {
attrs.push_str(&format!(r#" class="{}""#, escape_attr(class)));
}
if comp.aria_hidden {
attrs.push_str(r#" aria-hidden="true""#);
}
if let Some(props) = &comp.props {
if !props.is_null() {
let json = props.to_string();
attrs.push_str(&format!(r#" data-r-client-props="{}""#, escape_attr(&json)));
}
}
let script_url = comp.script_url();
let script_js = escape_js_str(&script_url);
let nonce = crate::server::page_csp_nonce();
let nonce_attr = if nonce.is_empty() {
String::new()
} else {
format!(r#" nonce="{}""#, escape_attr(&nonce))
};
let script = if comp.lazy {
format!(
r#"<script type="module" defer{nonce_attr}>
const boot = () => import("{script_js}");
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {{
if ("requestIdleCallback" in window) requestIdleCallback(() => {{ void boot(); }}, {{ timeout: 2500 }});
else setTimeout(() => {{ void boot(); }}, 1);
}}
</script>"#
)
} else {
format!(
r#"<script type="module" defer{nonce_attr}>
import "{script_js}";
</script>"#
)
};
View::raw(format!("<div {attrs}></div>\n{script}"))
}
fn escape_js_str(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for c in value.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'<' => out.push_str("\\u003c"),
'>' => out.push_str("\\u003e"),
'&' => out.push_str("\\u0026"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
other => out.push(other),
}
}
out
}
#[allow(clippy::result_unit_err)]
pub fn validate_client_id(id: &str) -> Result<(), ()> {
if id.is_empty()
|| id.len() > 64
|| !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(());
}
Ok(())
}
fn escape_attr(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for c in value.chars() {
match c {
'&' => out.push_str("&"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_component_emits_mount_and_script() {
let html = match client_component(
ClientComponent::new("hero-particles").class("hero-particles"),
) {
View::Raw(s) => s,
_ => panic!("expected raw view"),
};
assert!(html.contains(r#"data-r-client="hero-particles""#));
assert!(html.contains(r#"id="r-client-hero-particles""#));
assert!(html.contains(r#"class="hero-particles""#));
assert!(html.contains(r#"import "/static/client/hero-particles.js""#));
assert!(
!html.contains("requestIdleCallback"),
"eager mounts import immediately"
);
assert!(
!html.contains("resuma:client-ready"),
"ready is signaled by bootClientComponent after init, not the import wrapper"
);
}
#[test]
fn client_component_lazy_defers_import() {
let html = match client_component(ClientComponent::new("hero-particles").lazy(true)) {
View::Raw(s) => s,
_ => panic!("expected raw view"),
};
assert!(html.contains("requestIdleCallback"));
assert!(html.contains(r#"import("/static/client/hero-particles.js")"#));
assert!(!html.contains("\nimport \"/static/client/hero-particles.js\";"));
}
#[test]
fn client_script_url_format() {
assert_eq!(
client_script_url("chart-unregistered"),
"/static/client/chart-unregistered.js"
);
assert_eq!(
client_script_path("chart-unregistered"),
"/static/client/chart-unregistered.js"
);
}
#[test]
fn client_script_url_includes_digest_after_register() {
let body = b"export default {};";
register_client_asset_digest("digest-demo", body);
let url = client_script_url("digest-demo");
let expected = format!("/static/client/digest-demo.js?v={}", content_digest(body));
assert_eq!(url, expected);
let html = match client_component(ClientComponent::new("digest-demo")) {
View::Raw(s) => s,
_ => panic!("expected raw view"),
};
assert!(html.contains(&format!(r#"import "{expected}""#)));
}
#[test]
fn digest_changes_when_bytes_change() {
register_client_asset_digest("digest-flip", b"a");
let a = client_script_url("digest-flip");
register_client_asset_digest("digest-flip", b"b");
let b = client_script_url("digest-flip");
assert_ne!(a, b);
assert!(a.starts_with("/static/client/digest-flip.js?v="));
assert!(b.starts_with("/static/client/digest-flip.js?v="));
}
#[test]
fn rejects_invalid_client_id() {
assert!(matches!(
client_component(ClientComponent::new(r#"bad"id"#)),
View::Empty
));
}
#[test]
fn valid_client_id_accepts_common_names() {
assert!(validate_client_id("hero-particles").is_ok());
assert!(validate_client_id("chart_v2").is_ok());
assert!(validate_client_id("").is_err());
assert!(validate_client_id("../escape").is_err());
}
}