Skip to main content

karbon_framework/livewire/
live_handler.rs

1use axum::extract::ws::{Message, WebSocket};
2use axum::response::{Html, IntoResponse, Response};
3use futures::{SinkExt, StreamExt};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7use super::live_component::LiveComponent;
8
9/// Wire format for client → server events
10#[derive(Debug, Deserialize)]
11struct ClientEvent {
12    event: String,
13    #[serde(default)]
14    params: HashMap<String, String>,
15}
16
17/// Wire format for server → client updates
18#[derive(Debug, Serialize)]
19struct ServerPatch {
20    html: String,
21}
22
23/// Render a LiveComponent as an initial HTML page.
24///
25/// Returns the component HTML wrapped in a minimal shell with the livewire client JS.
26/// The client connects via WebSocket and sends events back to the server.
27///
28/// ```ignore
29/// use framework::livewire::{LiveComponent, live_render, live_socket};
30///
31/// #[framework::get("/counter")]
32/// async fn counter_page() -> impl IntoResponse {
33///     let component = Counter { count: 0 };
34///     live_render(component, "/ws/counter")
35/// }
36///
37/// // WebSocket handler for the live component
38/// Router::new()
39///     .route("/ws/counter", get(|ws: WebSocketUpgrade| {
40///         ws.on_upgrade(|socket| live_socket(socket, Counter { count: 0 }))
41///     }))
42/// ```
43pub fn live_render(component: impl LiveComponent, ws_url: &str) -> Response {
44    let html = component.render();
45
46    let page = format!(
47        r#"<div id="lw-root">{html}</div>
48<script>{CLIENT_JS}</script>
49<script>LiveWire.connect("{ws_url}");</script>"#
50    );
51
52    Html(page).into_response()
53}
54
55/// Handle a WebSocket connection for a LiveComponent.
56///
57/// Receives events from the client, calls `handle_event`, re-renders, and sends
58/// the updated HTML back.
59pub async fn live_socket(socket: WebSocket, mut component: impl LiveComponent) {
60    component.mount().await;
61
62    let (mut tx, mut rx) = socket.split();
63
64    // Send initial render
65    let html = component.render();
66    let patch = serde_json::to_string(&ServerPatch { html }).unwrap_or_default();
67    let _ = tx.send(Message::Text(patch.into())).await;
68
69    while let Some(Ok(msg)) = rx.next().await {
70        let text = match msg {
71            Message::Text(t) => t.to_string(),
72            Message::Close(_) => break,
73            _ => continue,
74        };
75
76        let Ok(event) = serde_json::from_str::<ClientEvent>(&text) else {
77            continue;
78        };
79
80        component.handle_event(&event.event, &event.params).await;
81
82        let html = component.render();
83        let patch = serde_json::to_string(&ServerPatch { html }).unwrap_or_default();
84        if tx.send(Message::Text(patch.into())).await.is_err() {
85            break;
86        }
87    }
88}
89
90/// Minimal client-side JS runtime for LiveWire components.
91/// Handles WebSocket connection, DOM patching, and event binding.
92const CLIENT_JS: &str = r#"
93const LiveWire = {
94    ws: null,
95    connect(url) {
96        const wsUrl = (location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host + url;
97        this.ws = new WebSocket(wsUrl);
98        this.ws.onmessage = (e) => {
99            try {
100                const patch = JSON.parse(e.data);
101                if (patch.html) {
102                    document.getElementById('lw-root').innerHTML = patch.html;
103                    this.bind();
104                }
105            } catch {}
106        };
107        this.ws.onclose = () => setTimeout(() => this.connect(url), 2000);
108        this.bind();
109    },
110    bind() {
111        document.querySelectorAll('[lw-click]').forEach(el => {
112            if (el._lw) return;
113            el._lw = true;
114            el.addEventListener('click', () => {
115                const event = el.getAttribute('lw-click');
116                const params = {};
117                for (const attr of el.attributes) {
118                    if (attr.name.startsWith('lw-param-')) {
119                        params[attr.name.slice(9)] = attr.value;
120                    }
121                }
122                this.send(event, params);
123            });
124        });
125        document.querySelectorAll('[lw-submit]').forEach(el => {
126            if (el._lw) return;
127            el._lw = true;
128            el.addEventListener('submit', (e) => {
129                e.preventDefault();
130                const event = el.getAttribute('lw-submit');
131                const params = {};
132                const formData = new FormData(el);
133                for (const [k, v] of formData) params[k] = v;
134                this.send(event, params);
135            });
136        });
137        document.querySelectorAll('[lw-input]').forEach(el => {
138            if (el._lw) return;
139            el._lw = true;
140            el.addEventListener('input', () => {
141                const event = el.getAttribute('lw-input');
142                this.send(event, { value: el.value });
143            });
144        });
145    },
146    send(event, params) {
147        if (this.ws && this.ws.readyState === 1) {
148            this.ws.send(JSON.stringify({ event, params: params || {} }));
149        }
150    }
151};
152"#;