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}