Skip to main content

renox_core/live_component/
mod.rs

1//! Live components: server-rendered pieces of a page whose state travels in a
2//! signed snapshot and is changed by actions sent back to the server.
3
4pub(crate) mod snapshot;
5
6pub(crate) mod route;
7
8mod context;
9
10use std::collections::HashMap;
11use std::future::Future;
12use std::pin::Pin;
13use std::sync::Arc;
14
15use axum::response::Response;
16
17use serde::Serialize;
18use serde::de::DeserializeOwned;
19
20pub use context::LiveContext;
21
22use crate::{Error, Result};
23
24/// A component rendered for a page: give it to the template as `component`
25/// and include `renox/live.html`.
26#[derive(Serialize)]
27#[non_exhaustive]
28pub struct Mounted {
29    /// The component's name.
30    pub name: &'static str,
31    /// The element id of its wrapper.
32    pub id: String,
33    /// The template that renders it.
34    pub view: &'static str,
35    /// The signed snapshot of its state.
36    pub snapshot: String,
37    /// The state, for the view.
38    pub state: serde_json::Value,
39    /// The extra values from [`LiveComponent::data`], for the view.
40    pub data: serde_json::Value,
41    /// True when an action's answer renders it (no scripts, no wrapper setup).
42    pub answer: bool,
43}
44
45/// Checks that an action got exactly `n` arguments (a 400 otherwise).
46///
47/// ```
48/// # use renox::prelude::*;
49/// let args = vec![serde_json::json!(1)];
50/// assert!(renox::live_component::arity(&args, 1).is_ok());
51/// assert!(renox::live_component::arity(&args, 2).is_err());
52/// ```
53pub fn arity(args: &[serde_json::Value], n: usize) -> Result {
54    if args.len() == n {
55        Ok(())
56    } else {
57        Err(Error::BadRequest(format!(
58            "expected {n} argument(s), got {}",
59            args.len()
60        )))
61    }
62}
63
64/// Reads argument `i` of an action as a `T` (a 400 when it is missing or
65/// has the wrong type).
66///
67/// ```
68/// # use renox::prelude::*;
69/// let args = vec![serde_json::json!(7), serde_json::json!("a")];
70/// let n: i64 = renox::live_component::arg(&args, 0)?;
71/// assert_eq!(n, 7);
72/// assert!(renox::live_component::arg::<i64>(&args, 1).is_err());
73/// assert!(renox::live_component::arg::<i64>(&args, 2).is_err());
74/// # Ok::<(), Error>(())
75/// ```
76pub fn arg<T: DeserializeOwned>(args: &[serde_json::Value], i: usize) -> Result<T> {
77    let value = args
78        .get(i)
79        .ok_or_else(|| Error::BadRequest(format!("missing argument {i}")))?;
80    serde_json::from_value(value.clone())
81        .map_err(|e| Error::BadRequest(format!("argument {i}: {e}")))
82}
83
84/// Renders `c` into a [`Mounted`].
85pub(crate) async fn render<C: LiveComponent>(
86    ctx: &LiveContext,
87    c: &C,
88    id: String,
89    answer: bool,
90) -> Result<Mounted> {
91    let state = serde_json::to_value(c).map_err(|e| Error::Internal(e.into()))?;
92    let data = c.data(ctx).await?;
93    let snapshot = snapshot::seal(
94        ctx.state.key.signing(),
95        ctx.state.config.live_snapshot_max,
96        C::NAME,
97        &id,
98        &state,
99    )?;
100    Ok(Mounted {
101        name: C::NAME,
102        id,
103        view: C::VIEW,
104        snapshot,
105        state,
106        data,
107        answer,
108    })
109}
110
111/// A registered component's entry point: context, action name and the raw
112/// request fields in, a response out.
113pub(crate) type LiveFn = Arc<
114    dyn Fn(
115            LiveContext,
116            String,
117            Vec<(String, String)>,
118        ) -> Pin<Box<dyn Future<Output = Result<Response>> + Send>>
119        + Send
120        + Sync,
121>;
122
123/// Registered components by name.
124pub(crate) type LiveMap = Arc<HashMap<&'static str, LiveFn>>;
125
126/// The entry point registered for `C`.
127pub(crate) fn handler<C: LiveComponent>() -> LiveFn {
128    Arc::new(|ctx, action, fields| Box::pin(route::run::<C>(ctx, action, fields)))
129}
130
131/// A live component: a serde struct whose fields are the state, and named
132/// actions that change it. The state travels in a signed snapshot.
133///
134/// `call` is a `match` on the action's name; answer unknown names with
135/// `Error::NotFound`. Names starting with `_` are reserved for the framework.
136///
137/// ```
138/// # use renox::prelude::*;
139/// # use renox::live_component::LiveContext;
140/// #[derive(serde::Serialize, serde::Deserialize)]
141/// struct Counter {
142///     count: i64,
143/// }
144///
145/// impl LiveComponent for Counter {
146///     const NAME: &'static str = "counter";
147///     const VIEW: &'static str = "live/counter.html";
148///
149///     async fn call(
150///         &mut self,
151///         action: &str,
152///         _args: Vec<serde_json::Value>,
153///         _ctx: &mut LiveContext,
154///     ) -> Result {
155///         match action {
156///             "increment" => {
157///                 self.count += 1;
158///                 Ok(())
159///             }
160///             _ => Err(Error::NotFound),
161///         }
162///     }
163/// }
164/// ```
165pub trait LiveComponent: Serialize + DeserializeOwned + Send + Sync + 'static {
166    /// The component's name, used in its route and its snapshot.
167    const NAME: &'static str;
168
169    /// The template that renders the component.
170    const VIEW: &'static str;
171
172    /// Extra values for the view, next to the state (as `data`). Empty by default.
173    ///
174    /// ```
175    /// # use renox::prelude::*;
176    /// # use renox::live_component::LiveContext;
177    /// # #[derive(serde::Serialize, serde::Deserialize)]
178    /// # struct Items { limit: i64 }
179    /// # impl LiveComponent for Items {
180    /// #     const NAME: &'static str = "items";
181    /// #     const VIEW: &'static str = "live/items.html";
182    /// async fn data(&self, ctx: &LiveContext) -> Result<serde_json::Value> {
183    ///     let _ = ctx.state();
184    ///     Ok(json!({ "shown": self.limit }))
185    /// }
186    /// #     async fn call(&mut self, _: &str, _: Vec<serde_json::Value>, _: &mut LiveContext) -> Result {
187    /// #         Ok(())
188    /// #     }
189    /// # }
190    /// ```
191    fn data(&self, ctx: &LiveContext) -> impl Future<Output = Result<serde_json::Value>> + Send {
192        let _ = ctx;
193        async { Ok(serde_json::Value::Object(Default::default())) }
194    }
195
196    /// Runs the action `action` with its `args`, changing `self` and using
197    /// `ctx` for the request, toasts, redirects and events.
198    ///
199    /// ```
200    /// # use renox::prelude::*;
201    /// # use renox::live_component::LiveContext;
202    /// # #[derive(serde::Serialize, serde::Deserialize)]
203    /// # struct Note { text: String }
204    /// # impl LiveComponent for Note {
205    /// #     const NAME: &'static str = "note";
206    /// #     const VIEW: &'static str = "live/note.html";
207    /// async fn call(
208    ///     &mut self,
209    ///     action: &str,
210    ///     args: Vec<serde_json::Value>,
211    ///     ctx: &mut LiveContext,
212    /// ) -> Result {
213    ///     match action {
214    ///         "clear" => {
215    ///             self.text.clear();
216    ///             ctx.toast(Toast::success("Cleared"));
217    ///             Ok(())
218    ///         }
219    ///         _ => Err(Error::NotFound),
220    ///     }
221    /// }
222    /// # }
223    /// ```
224    fn call(
225        &mut self,
226        action: &str,
227        args: Vec<serde_json::Value>,
228        ctx: &mut LiveContext,
229    ) -> impl Future<Output = Result> + Send;
230}