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/// The full keyring for snapshot verification. Pulls `APP_KEYS` when set
89/// (`"1:keyA,2:keyB"` form — first entry is the active signing key); falls
90/// back to a single `(0, APP_KEY)` pair when `APP_KEYS` is absent, so apps
91/// that don't rotate stay one-line.
92///
93/// Returned `(kid, key)` pairs are owned `String`s — caller borrows as
94/// `&[(u8, &str)]` before handing to `Envelope::verify_with_keys`.
95pub 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
109/// Build the wrapped DOM string for a freshly-mounted component.
110pub 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('&', "&amp;")
132        .replace('"', "&quot;")
133        .replace('<', "&lt;")
134        .replace('>', "&gt;")
135}
136
137/// Mount + initial-render a component. Returns the wrapped HTML to splice
138/// into the parent template. Called by the `@spark` forge directive at runtime.
139pub 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
169/// Boot script emitted by `@sparkScripts`. Drains the request's mounts and
170/// embeds them in a JSON literal next to the runtime <script> tag.
171pub 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
202/// Mount a component from a known snapshot data + memo (re-render path). Used by
203/// the `/_spark/update` handler after dispatching an action.
204pub 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
213/// Wrap re-rendered HTML for `/_spark/update` responses. The browser-side
214/// runtime will morph this back into the existing component subtree.
215pub fn wrap_rerender(html: &str, memo: &Memo, snapshot_wire: &str) -> String {
216    wrap(html, memo, snapshot_wire)
217}