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