Skip to main content

webui/
server.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! High-level server helper for custom Rust servers using `webui-router`.
5//!
6//! This module provides [`serve_request`] which encapsulates:
7//! - Route parameter extraction from the URL
8//! - HTML SSR rendering via [`WebUIHandler`]
9//! - JSON partial responses for client-side navigation
10//! - Template inventory management
11//!
12//! # Example
13//!
14//! ```rust,ignore
15//! use webui::server::{serve_request, ServeRequest, ServeResponse};
16//!
17//! let request = ServeRequest {
18//!     path: "/email/thread-5",
19//!     accept_json: false,
20//!     inventory_hex: "",
21//! };
22//!
23//! match serve_request(&protocol, &handler, &state, "index.html", &request) {
24//!     Ok(ServeResponse::Html(html)) => { /* serve HTML */ }
25//!     Ok(ServeResponse::Json(json)) => { /* serve JSON */ }
26//!     Err(e) => { /* handle error */ }
27//! }
28//! ```
29
30use crate::{ResponseWriter, WebUIHandler};
31use webui_handler::route_handler;
32use webui_handler::RenderOptions;
33use webui_protocol::WebUIProtocol;
34
35/// A server request to be handled by [`serve_request`].
36pub struct ServeRequest<'a> {
37    /// The URL path (e.g., `"/email/thread-5"`, `"/folder/sent"`).
38    pub path: &'a str,
39    /// Whether the client accepts JSON (for partial navigation).
40    /// Check `Accept: application/json` in request headers.
41    pub accept_json: bool,
42    /// The client's current template inventory (hex bitmask).
43    /// Read from `X-WebUI-Inventory` request header. Empty string if not present.
44    pub inventory_hex: &'a str,
45}
46
47/// The response from [`serve_request`].
48pub enum ServeResponse {
49    /// Full HTML page for initial load or browser refresh.
50    Html(String),
51    /// JSON partial for client-side navigation via `webui-router`.
52    Json(serde_json::Value),
53}
54
55/// Handle a server request with automatic route handling.
56///
57/// For HTML requests: renders the full page with route-matched SSR,
58/// injects `__webui_state` and `__webui_templates`.
59///
60/// For JSON requests: returns a partial response with route-scoped state,
61/// needed templates, and inventory for the `webui-router` client.
62///
63/// # Arguments
64/// - `protocol` — The compiled WebUI protocol from [`build`](crate::build)
65/// - `handler` — The WebUI handler (with plugin configured)
66/// - `state` — The state JSON to render. For HTML requests, this should be
67///   the full app state. For JSON requests, the caller should provide
68///   route-scoped state (only what the target page component needs).
69/// - `request` — The incoming request details
70///
71/// # Route Parameters
72/// Route parameters (`:param` in route paths) are automatically extracted
73/// and injected into the state object.
74pub fn serve_request(
75    protocol: &WebUIProtocol,
76    handler: &WebUIHandler,
77    state: serde_json::Value,
78    entry: &str,
79    request: &ServeRequest<'_>,
80) -> Result<ServeResponse, String> {
81    // Extract route params and inject into state
82    let params = route_handler::collect_nested_route_params(protocol, entry, request.path);
83    let mut data = state;
84    if let Some(map) = data.as_object_mut() {
85        for (k, v) in &params {
86            map.insert(k.clone(), serde_json::Value::String(v.clone()));
87        }
88    }
89
90    if request.accept_json {
91        // JSON partial response for client-side navigation.
92        // render_partial() produces the complete response shape including
93        // separated templateStyles and templates arrays.
94        let partial = route_handler::render_partial(
95            protocol,
96            data,
97            entry,
98            request.path,
99            request.inventory_hex,
100        );
101        Ok(ServeResponse::Json(partial))
102    } else {
103        // Full HTML SSR
104        let mut writer = MemWriter::with_capacity(131_072);
105        let opts = RenderOptions::new(entry, request.path);
106        handler
107            .handle(protocol, &data, &opts, &mut writer)
108            .map_err(|e| format!("render failed: {e}"))?;
109
110        let state_json =
111            serde_json::to_string(&data).map_err(|e| format!("serialize failed: {e}"))?;
112        let safe_json = state_json.replace("</", "<\\/");
113        let script = format!("<script>window.__webui_state={safe_json}</script>");
114        let html = writer.buf.replace("</body>", &format!("{script}</body>"));
115
116        Ok(ServeResponse::Html(html))
117    }
118}
119
120struct MemWriter {
121    buf: String,
122}
123
124impl MemWriter {
125    fn with_capacity(cap: usize) -> Self {
126        Self {
127            buf: String::with_capacity(cap),
128        }
129    }
130}
131
132impl ResponseWriter for MemWriter {
133    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
134        self.buf.push_str(content);
135        Ok(())
136    }
137
138    fn end(&mut self) -> webui_handler::Result<()> {
139        Ok(())
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use serde_json::json;
147    use std::collections::HashMap;
148    use webui_protocol::{FragmentList, WebUIFragment};
149
150    #[test]
151    fn serve_request_json_partial_preserves_template_styles() {
152        let mut fragments = HashMap::new();
153        fragments.insert(
154            "index.html".to_string(),
155            FragmentList {
156                fragments: vec![WebUIFragment::component("my-page")],
157            },
158        );
159        fragments.insert(
160            "my-page".to_string(),
161            FragmentList {
162                fragments: vec![WebUIFragment::raw("<p>page</p>")],
163            },
164        );
165
166        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
167        let component = protocol
168            .components
169            .entry("my-page".to_string())
170            .or_default();
171        component.template =
172            "(function(){window.__webui_templates['my-page']={h:'<p>page</p>'};})();".to_string();
173        component.css = ".page{color:red}".to_string();
174
175        let handler = WebUIHandler::new();
176        let request = ServeRequest {
177            path: "/",
178            accept_json: true,
179            inventory_hex: "",
180        };
181
182        let response = serve_request(&protocol, &handler, json!({}), "index.html", &request)
183            .expect("partial response should succeed");
184
185        let json = match response {
186            ServeResponse::Json(value) => value,
187            ServeResponse::Html(_) => panic!("expected JSON partial response"),
188        };
189
190        // templateStyles must be present and non-empty for module-mode components
191        assert_eq!(
192            json["templateStyles"].as_array().map(Vec::len),
193            Some(1),
194            "serve_request should include module template styles"
195        );
196        // templates must not contain any style tags
197        assert!(
198            !json["templates"][0]
199                .as_str()
200                .unwrap_or_default()
201                .contains("<style"),
202            "template scripts should not contain module style tags"
203        );
204    }
205
206    #[test]
207    fn serve_request_link_strategy_returns_empty_template_styles() {
208        let mut fragments = HashMap::new();
209        fragments.insert(
210            "index.html".to_string(),
211            FragmentList {
212                fragments: vec![WebUIFragment::component("my-page")],
213            },
214        );
215        fragments.insert(
216            "my-page".to_string(),
217            FragmentList {
218                fragments: vec![WebUIFragment::raw("<p>page</p>")],
219            },
220        );
221
222        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
223        let comp = protocol
224            .components
225            .entry("my-page".to_string())
226            .or_default();
227        comp.template = "(function(){})();".to_string();
228        comp.css_href = "/my-page.css".to_string();
229        // No css content — Link strategy
230
231        let handler = WebUIHandler::new();
232        let request = ServeRequest {
233            path: "/",
234            accept_json: true,
235            inventory_hex: "",
236        };
237
238        let response = serve_request(&protocol, &handler, json!({}), "index.html", &request)
239            .expect("partial response should succeed");
240
241        let json = match response {
242            ServeResponse::Json(value) => value,
243            ServeResponse::Html(_) => panic!("expected JSON partial response"),
244        };
245
246        assert!(
247            json["templateStyles"]
248                .as_array()
249                .is_some_and(|a| a.is_empty()),
250            "Link strategy should return empty templateStyles"
251        );
252        assert!(
253            json["templates"].as_array().is_some_and(|a| a.len() == 1),
254            "Link strategy should still return templates"
255        );
256    }
257
258    #[test]
259    fn serve_request_style_strategy_returns_empty_template_styles() {
260        let mut fragments = HashMap::new();
261        fragments.insert(
262            "index.html".to_string(),
263            FragmentList {
264                fragments: vec![WebUIFragment::component("my-page")],
265            },
266        );
267        fragments.insert(
268            "my-page".to_string(),
269            FragmentList {
270                fragments: vec![WebUIFragment::raw("<p>page</p>")],
271            },
272        );
273
274        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
275        let comp = protocol
276            .components
277            .entry("my-page".to_string())
278            .or_default();
279        // Style strategy: CSS is inlined in the template IIFE
280        comp.template = "(function(){var w=window.__webui_templates;w['my-page']={h:'<style>.p{color:red}</style><p/>'};})();".to_string();
281
282        let handler = WebUIHandler::new();
283        let request = ServeRequest {
284            path: "/",
285            accept_json: true,
286            inventory_hex: "",
287        };
288
289        let response = serve_request(&protocol, &handler, json!({}), "index.html", &request)
290            .expect("partial response should succeed");
291
292        let json = match response {
293            ServeResponse::Json(value) => value,
294            ServeResponse::Html(_) => panic!("expected JSON partial response"),
295        };
296
297        assert!(
298            json["templateStyles"]
299                .as_array()
300                .is_some_and(|a| a.is_empty()),
301            "Style strategy should return empty templateStyles"
302        );
303        assert!(
304            json["templates"].as_array().is_some_and(|a| a.len() == 1),
305            "Style strategy should still return templates"
306        );
307    }
308}