workers-rsx 0.1.0

A JSX-like templating engine for Cloudflare Workers
Documentation
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use worker::{Method, Request, Response};

use crate::fragments::diff_html;
use crate::render::Render;
use crate::state::{decode_state, state_init_script};

/// Trait for a reducer that knows how to initialize its state
/// and reduce actions.
pub trait Reducer: Serialize + DeserializeOwned + Clone {
    type Action: Serialize + DeserializeOwned;

    fn initial() -> Self;
    fn reduce(&self, action: Self::Action) -> Self;
}

#[derive(Deserialize)]
struct DispatchRequest<A> {
    _state: Option<String>,
    #[serde(flatten)]
    action: Option<A>,
}

/// Generic htmx dispatch: decode state → extract action → reduce → diff → return HTML.
///
/// Returns `Some(html)` with the OOB diff fragments, or `None` if no action was found.
pub fn dispatch<S, V>(body: &str) -> Option<String>
where
    S: Reducer,
    V: Render + From<S>,
{
    let parsed: DispatchRequest<S::Action> = serde_json::from_str(body).ok()?;

    let old_state = parsed
        ._state
        .as_deref()
        .and_then(decode_state::<S>)
        .unwrap_or_else(S::initial);

    let action = parsed.action?;

    let new_state = old_state.reduce(action);
    let old_html = Render::render(V::from(old_state));
    let new_html = Render::render(V::from(new_state.clone()));

    Some(diff_html(&old_html, &new_html, &new_state))
}

/// Handle an HTTP request for an htmx app.
///
/// - POST with `HX-Request` header: parses the JSON body, dispatches the action, returns diff HTML.
/// - Everything else: returns a full-page render from `S::initial()`.
///
/// ```ignore
/// #[event(fetch)]
/// pub async fn main(req: Request, _env: Env, _ctx: Context) -> Result<Response> {
///     handle::<PageState, TodosPage>(req).await
/// }
/// ```
pub async fn handle<S, V>(mut req: Request) -> worker::Result<Response>
where
    S: Reducer,
    V: Render + From<S>,
{
    let is_htmx_post = req.method() == Method::Post
        && req.headers().get("HX-Request").ok().flatten().is_some();

    if is_htmx_post {
        let body = req.text().await?;
        match dispatch::<S, V>(&body) {
            Some(html) => Response::from_html(html),
            None => Response::ok(""),
        }
    } else {
        let state = S::initial();
        let mut html = Render::render(V::from(state.clone()));
        // Inject state script before </body>
        if let Some(pos) = html.rfind("</body>") {
            html.insert_str(pos, &state_init_script(&state));
        }
        Response::from_html(html)
    }
}