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,
}
impl ClientComponent {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
class: None,
props: None,
aria_hidden: true,
}
}
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 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 path = client_script_path(id);
match CLIENT_DIGESTS.read().get(id) {
Some(v) => format!("{path}?v={v}"),
None => path,
}
}
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 = escape_attr(&comp.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))
};
View::raw(format!(
r#"<div {attrs}></div>
<script type="module" src="{script}" defer{nonce_attr}></script>"#
))
}
#[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#"src="/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#"src="{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());
}
}