Skip to main content

spark/
render.rs

1//! Server-side mount rendering: produces the `<div spark:id="..." spark:snapshot="...">…</div>`
2//! wrapper that the JS runtime hydrates on page load.
3//!
4//! Mirrors `forge::stack` — per-request mount metadata accumulates in a
5//! `tokio::task_local!` buffer that `@sparkScripts` drains at the bottom of the page.
6
7use 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
40/// Drain the per-request mount metadata. Used by `boot_script()`.
41pub 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
47/// Run a future within a fresh per-request mount scope. Hooked from the
48/// `spark.scope` middleware.
49pub 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
61/// Run `fut` with both a fresh mount scope AND a CSRF token bound for `boot_script`.
62pub 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
71/// Configured APP_KEY + whether to encrypt snapshots.
72pub 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
88/// Build the wrapped DOM string for a freshly-mounted component.
89pub fn wrap(html: &str, memo: &Memo, snapshot_wire: &str) -> String {
90    let listeners_attr = if memo.listeners.is_empty() {
91        String::new()
92    } else {
93        format!(
94            r#" spark:listen="{}""#,
95            escape_attr(&memo.listeners.join(","))
96        )
97    };
98    format!(
99        r#"<div spark:id="{id}" spark:name="{view}" spark:class="{class}" spark:snapshot="{snapshot}"{listeners}>{html}</div>"#,
100        id = escape_attr(&memo.id),
101        view = escape_attr(&memo.view),
102        class = escape_attr(&memo.class),
103        snapshot = escape_attr(snapshot_wire),
104        listeners = listeners_attr,
105        html = html
106    )
107}
108
109fn escape_attr(s: &str) -> String {
110    s.replace('&', "&amp;")
111        .replace('"', "&quot;")
112        .replace('<', "&lt;")
113        .replace('>', "&gt;")
114}
115
116/// Mount + initial-render a component. Returns the wrapped HTML to splice
117/// into the parent template. Called by the `@spark` forge directive at runtime.
118pub fn render_mount(name: &str, props: &serde_json::Value) -> Result<String> {
119    let entry = registry::resolve(name)?;
120    let mount_props = MountProps::new(props.clone());
121    let component: BoxedComponent = (entry.mount)(mount_props);
122    let id = Uuid::now_v7().to_string();
123    let memo = Memo {
124        id: id.clone(),
125        class: component.class.to_string(),
126        view: component.view.to_string(),
127        listeners: (entry.listeners)(),
128        errors: None,
129    };
130
131    let html = component.state.render()?;
132    let data = component.state.snapshot_data();
133
134    let (app_key, encrypt) = signing();
135    let envelope = Envelope::build(&app_key, data, memo.clone());
136    let wire = snapshot::encode(&envelope, &app_key, encrypt)?;
137
138    push_mount(MountInfo {
139        id: memo.id.clone(),
140        class: memo.class.clone(),
141        listeners: memo.listeners.clone(),
142    });
143
144    Ok(wrap(&html, &memo, &wire))
145}
146
147/// Boot script emitted by `@sparkScripts`. Drains the request's mounts and
148/// embeds them in a JSON literal next to the runtime <script> tag.
149pub fn boot_script() -> String {
150    let mounts = drain_mounts();
151    let csrf = current_csrf();
152    let endpoint = std::env::var("SPARK_UPDATE_PATH").unwrap_or_else(|_| "/_spark/update".into());
153    let runtime = std::env::var("SPARK_RUNTIME_PATH").unwrap_or_else(|_| "/_spark/spark.js".into());
154
155    #[derive(Serialize)]
156    struct Boot<'a> {
157        csrf: &'a str,
158        endpoint: &'a str,
159        mounts: &'a [MountInfo],
160    }
161    let boot = Boot {
162        csrf: &csrf,
163        endpoint: &endpoint,
164        mounts: &mounts,
165    };
166    let json = serde_json::to_string(&boot).unwrap_or_else(|_| "{}".into());
167
168    format!(
169        r#"<script src="{runtime}" defer></script>
170<script>window.__spark_boot={json};</script>"#
171    )
172}
173
174pub(crate) fn current_csrf() -> String {
175    CURRENT_CSRF
176        .try_with(|t| t.clone())
177        .unwrap_or_else(|_| String::new())
178}
179
180/// Mount a component from a known snapshot data + memo (re-render path). Used by
181/// the `/_spark/update` handler after dispatching an action.
182pub fn rerender(component: &BoxedComponent, memo: &Memo) -> Result<(String, String)> {
183    let html = component.state.render()?;
184    let data = component.state.snapshot_data();
185    let (app_key, encrypt) = signing();
186    let envelope = Envelope::build(&app_key, data, memo.clone());
187    let wire = snapshot::encode(&envelope, &app_key, encrypt)?;
188    Ok((html, wire))
189}
190
191/// Wrap re-rendered HTML for `/_spark/update` responses. The browser-side
192/// runtime will morph this back into the existing component subtree.
193pub fn wrap_rerender(html: &str, memo: &Memo, snapshot_wire: &str) -> String {
194    wrap(html, memo, snapshot_wire)
195}