1use parking_lot::Mutex;
8use serde::Serialize;
9use uuid::Uuid;
10
11use crate::component::MountProps;
12use crate::error::Result;
13use crate::registry::{self, BoxedComponent};
14use crate::snapshot::{self, Envelope, Memo};
15
16tokio::task_local! {
17 static REQUEST_MOUNTS: Mutex<Vec<MountInfo>>;
18 pub(crate) static CURRENT_CSRF: String;
19}
20
21static GLOBAL_MOUNTS: once_cell::sync::Lazy<Mutex<Vec<MountInfo>>> =
22 once_cell::sync::Lazy::new(|| Mutex::new(Vec::new()));
23
24#[derive(Debug, Clone, Serialize)]
25pub struct MountInfo {
26 pub id: String,
27 pub class: String,
28 pub listeners: Vec<String>,
29}
30
31fn push_mount(info: MountInfo) {
32 let pushed = REQUEST_MOUNTS.try_with(|m| {
33 m.lock().push(info.clone());
34 });
35 if pushed.is_err() {
36 GLOBAL_MOUNTS.lock().push(info);
37 }
38}
39
40pub fn drain_mounts() -> Vec<MountInfo> {
42 REQUEST_MOUNTS
43 .try_with(|m| std::mem::take(&mut *m.lock()))
44 .unwrap_or_else(|_| std::mem::take(&mut *GLOBAL_MOUNTS.lock()))
45}
46
47pub async fn with_request_scope<F, T>(fut: F) -> T
50where
51 F: std::future::Future<Output = T>,
52{
53 REQUEST_MOUNTS
54 .scope(
55 Mutex::new(Vec::new()),
56 CURRENT_CSRF.scope(String::new(), fut),
57 )
58 .await
59}
60
61pub async fn with_request_scope_csrf<F, T>(csrf: String, fut: F) -> T
63where
64 F: std::future::Future<Output = T>,
65{
66 REQUEST_MOUNTS
67 .scope(Mutex::new(Vec::new()), CURRENT_CSRF.scope(csrf, fut))
68 .await
69}
70
71pub fn signing() -> (String, bool) {
73 let container = anvil_core::container::try_current();
74 let key = container
75 .as_ref()
76 .map(|c| c.app().key.clone())
77 .filter(|k| !k.is_empty())
78 .unwrap_or_else(|| {
79 std::env::var("APP_KEY").unwrap_or_else(|_| "spark-dev-key-please-rotate".into())
80 });
81 let encrypt = std::env::var("SPARK_ENCRYPT")
82 .ok()
83 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
84 .unwrap_or(false);
85 (key, encrypt)
86}
87
88pub fn keyring() -> Vec<(u8, String)> {
96 if let Ok(raw) = std::env::var("APP_KEYS") {
97 let parsed = crate::snapshot::parse_keyring(&raw);
98 if !parsed.is_empty() {
99 return parsed;
100 }
101 tracing::warn!(
102 "APP_KEYS set but no valid `kid:key` entries parsed — falling back to APP_KEY"
103 );
104 }
105 let (key, _) = signing();
106 vec![(0, key)]
107}
108
109pub fn wrap(html: &str, memo: &Memo, snapshot_wire: &str) -> String {
111 let listeners_attr = if memo.listeners.is_empty() {
112 String::new()
113 } else {
114 format!(
115 r#" spark:listen="{}""#,
116 escape_attr(&memo.listeners.join(","))
117 )
118 };
119 format!(
120 r#"<div spark:id="{id}" spark:name="{view}" spark:class="{class}" spark:snapshot="{snapshot}"{listeners}>{html}</div>"#,
121 id = escape_attr(&memo.id),
122 view = escape_attr(&memo.view),
123 class = escape_attr(&memo.class),
124 snapshot = escape_attr(snapshot_wire),
125 listeners = listeners_attr,
126 html = html
127 )
128}
129
130fn escape_attr(s: &str) -> String {
131 s.replace('&', "&")
132 .replace('"', """)
133 .replace('<', "<")
134 .replace('>', ">")
135}
136
137pub fn render_mount(name: &str, props: &serde_json::Value) -> Result<String> {
140 let entry = registry::resolve(name)?;
141 let mount_props = MountProps::new(props.clone());
142 let component: BoxedComponent = (entry.mount)(mount_props);
143 let id = Uuid::now_v7().to_string();
144 let memo = Memo {
145 id: id.clone(),
146 class: component.class.to_string(),
147 view: component.view.to_string(),
148 listeners: (entry.listeners)(),
149 errors: None,
150 rev: 0,
151 };
152
153 let html = component.state.render()?;
154 let data = component.state.snapshot_data();
155
156 let (app_key, encrypt) = signing();
157 let envelope = Envelope::build(&app_key, data, memo.clone());
158 let wire = snapshot::encode(&envelope, &app_key, encrypt)?;
159
160 push_mount(MountInfo {
161 id: memo.id.clone(),
162 class: memo.class.clone(),
163 listeners: memo.listeners.clone(),
164 });
165
166 Ok(wrap(&html, &memo, &wire))
167}
168
169pub fn boot_script() -> String {
172 let mounts = drain_mounts();
173 let csrf = current_csrf();
174 let endpoint = std::env::var("SPARK_UPDATE_PATH").unwrap_or_else(|_| "/_spark/update".into());
175 let runtime = std::env::var("SPARK_RUNTIME_PATH").unwrap_or_else(|_| "/_spark/spark.js".into());
176
177 #[derive(Serialize)]
178 struct Boot<'a> {
179 csrf: &'a str,
180 endpoint: &'a str,
181 mounts: &'a [MountInfo],
182 }
183 let boot = Boot {
184 csrf: &csrf,
185 endpoint: &endpoint,
186 mounts: &mounts,
187 };
188 let json = serde_json::to_string(&boot).unwrap_or_else(|_| "{}".into());
189
190 format!(
191 r#"<script src="{runtime}" defer></script>
192<script>window.__spark_boot={json};</script>"#
193 )
194}
195
196pub(crate) fn current_csrf() -> String {
197 CURRENT_CSRF
198 .try_with(|t| t.clone())
199 .unwrap_or_else(|_| String::new())
200}
201
202pub fn rerender(component: &BoxedComponent, memo: &Memo) -> Result<(String, String)> {
205 let html = component.state.render()?;
206 let data = component.state.snapshot_data();
207 let (app_key, encrypt) = signing();
208 let envelope = Envelope::build(&app_key, data, memo.clone());
209 let wire = snapshot::encode(&envelope, &app_key, encrypt)?;
210 Ok((html, wire))
211}
212
213pub fn wrap_rerender(html: &str, memo: &Memo, snapshot_wire: &str) -> String {
216 wrap(html, memo, snapshot_wire)
217}