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::{
16//!     server::{serve_request, ServeRequest, ServeResponse},
17//!     RenderOptions,
18//! };
19//!
20//! let options = RenderOptions::new("index.html", "/email/thread-5")
21//!     .with_nonce("request-csp-nonce");
22//! let request = ServeRequest::new(options, false, "");
23//!
24//! match serve_request(&protocol, &handler, state, &request) {
25//!     Ok(ServeResponse::Html(html)) => { /* serve HTML */ }
26//!     Ok(ServeResponse::Json(json)) => { /* serve JSON */ }
27//!     Err(e) => { /* handle error */ }
28//! }
29//! ```
30
31use crate::{Protocol, WebUIHandler};
32use webui_handler::route_handler;
33use webui_handler::RenderOptions;
34
35webui_handler::define_string_response_writer!(MemWriter, buf);
36
37/// A server request to be handled by [`serve_request`].
38pub struct ServeRequest<'a> {
39    render_options: RenderOptions<'a>,
40    accept_json: bool,
41    inventory_hex: &'a str,
42}
43
44impl<'a> ServeRequest<'a> {
45    /// Create a server request from the complete per-render configuration.
46    ///
47    /// Set `accept_json` when the request accepts a partial-navigation response.
48    /// `inventory_hex` is the client's `X-WebUI-Inventory` value, or an empty
49    /// string when the header is absent.
50    #[must_use]
51    #[inline]
52    pub fn new(
53        render_options: RenderOptions<'a>,
54        accept_json: bool,
55        inventory_hex: &'a str,
56    ) -> Self {
57        Self {
58            render_options,
59            accept_json,
60            inventory_hex,
61        }
62    }
63}
64
65/// The response from [`serve_request`].
66pub enum ServeResponse {
67    /// Full HTML page for initial load or browser refresh.
68    Html(String),
69    /// JSON partial for client-side navigation via `webui-router`.
70    Json(String),
71}
72
73/// Handle a server request with automatic route handling.
74///
75/// For HTML requests: renders the full page with route-matched SSR.
76/// The handler emits a consolidated `window.__webui` script block
77/// containing state, chain, inventory, and template metadata.
78///
79/// For JSON requests: returns a partial response with route-scoped state,
80/// needed templates, and inventory for the `webui-router` client.
81///
82/// # Arguments
83/// - `protocol` — The compiled WebUI protocol from [`build`](crate::build)
84/// - `handler` — The WebUI handler (with plugin configured)
85/// - `state` — The state JSON to render. For HTML requests, this should be
86///   the full app state. For JSON requests, the caller should provide
87///   route-scoped state (only what the target page component needs).
88/// - `request` — The incoming request details and complete [`RenderOptions`]
89///
90/// # Route Parameters
91/// Route parameters (`:param` in route paths) are automatically extracted
92/// and injected into the state object.
93///
94/// # Content Security Policy
95/// Set the response nonce with [`RenderOptions::with_nonce`]. Full-document
96/// rendering forwards the options unchanged so every generated inline script
97/// and the `webui-nonce` metadata receive the request nonce.
98pub fn serve_request(
99    protocol: &Protocol,
100    handler: &WebUIHandler,
101    state: serde_json::Value,
102    request: &ServeRequest<'_>,
103) -> Result<ServeResponse, String> {
104    let options = &request.render_options;
105
106    // Extract route params and inject into state
107    let params = route_handler::collect_nested_route_params(
108        protocol,
109        options.entry_id,
110        options.request_path,
111    );
112    let mut data = state;
113    if let Some(map) = data.as_object_mut() {
114        for (key, value) in params {
115            map.insert(key, serde_json::Value::String(value));
116        }
117    }
118
119    if request.accept_json {
120        // JSON partial response for client-side navigation.
121        let partial = protocol
122            .render_partial(
123                data,
124                options.entry_id,
125                options.request_path,
126                request.inventory_hex,
127            )
128            .map_err(|e| format!("render_partial failed: {e}"))?;
129        Ok(ServeResponse::Json(partial))
130    } else {
131        // Full HTML SSR — handler emits the consolidated window.__webui
132        // script block (state, chain, inventory, templates) automatically.
133        let mut writer = MemWriter::with_capacity(131_072);
134        handler
135            .render(protocol, &data, options, &mut writer)
136            .map_err(|e| format!("render failed: {e}"))?;
137
138        Ok(ServeResponse::Html(writer.buf))
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use serde_json::json;
146    use std::collections::HashMap;
147    use webui_handler::plugin::webui::WebUIHydrationPlugin;
148    use webui_protocol::{
149        ComponentData, ComponentStyleClosure, CssStrategy, FragmentList, WebUIFragment,
150        WebUIProtocol,
151    };
152
153    fn structural_fragment(value: &str) -> WebUIFragment {
154        let mut signal = String::with_capacity("}}}webui:".len() + value.len());
155        signal.push_str("}}}webui:");
156        signal.push_str(value);
157        WebUIFragment::signal(signal, true)
158    }
159
160    fn add_page_style_closures(protocol: &mut WebUIProtocol) {
161        let closure = ComponentStyleClosure {
162            component_tags: vec!["my-page".to_string()],
163            style_chunks: Vec::new(),
164        };
165        protocol
166            .style_closures
167            .insert("index.html".to_string(), closure.clone());
168        protocol
169            .style_closures
170            .insert("my-page".to_string(), closure);
171    }
172
173    fn response_json(response: ServeResponse) -> serde_json::Value {
174        match response {
175            ServeResponse::Json(json) => {
176                serde_json::from_str(&json).expect("partial response should be valid JSON")
177            }
178            ServeResponse::Html(_) => panic!("expected JSON partial response"),
179        }
180    }
181
182    fn full_document_protocol() -> Protocol {
183        let fragments = HashMap::from([
184            (
185                "index.html".to_string(),
186                FragmentList {
187                    fragments: vec![
188                        WebUIFragment::raw("<!doctype html><html><head>"),
189                        structural_fragment("head_end"),
190                        WebUIFragment::raw("</head><body>"),
191                        WebUIFragment::component("my-page"),
192                        structural_fragment("body_end"),
193                        WebUIFragment::raw("</body></html>"),
194                    ],
195                    contains_boundary: false,
196                },
197            ),
198            (
199                "my-page".to_string(),
200                FragmentList {
201                    fragments: vec![WebUIFragment::raw("<main>ready</main>")],
202                    contains_boundary: false,
203                },
204            ),
205        ]);
206        let mut protocol = WebUIProtocol::new(fragments);
207        protocol.components.insert(
208            "my-page".to_string(),
209            ComponentData {
210                template_json: r#"{"h":"<main>ready</main>","th":1}"#.to_string(),
211                template_functions: "[function(){return true}]".to_string(),
212                ..Default::default()
213            },
214        );
215        Protocol::new(protocol)
216    }
217
218    #[test]
219    fn serve_request_html_forwards_complete_render_options() {
220        let protocol = full_document_protocol();
221        let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
222        let request = ServeRequest::new(
223            RenderOptions::new("index.html", "/")
224                .with_nonce("request-nonce")
225                .with_head_inject("<meta name=\"host-head\">")
226                .with_body_inject("<script src=\"host.js\"></script>"),
227            false,
228            "",
229        );
230
231        let response = serve_request(&protocol, &handler, json!({}), &request)
232            .expect("full-document response should succeed");
233        let html = match response {
234            ServeResponse::Html(html) => html,
235            ServeResponse::Json(_) => panic!("expected full-document response"),
236        };
237
238        let mut direct = MemWriter::with_capacity(131_072);
239        handler
240            .render(&protocol, &json!({}), &request.render_options, &mut direct)
241            .expect("direct render should succeed");
242
243        assert_eq!(html, direct.buf);
244        assert!(html.contains(r#"<meta name="webui-nonce" content="request-nonce">"#));
245        assert!(html.contains(r#"<script nonce="request-nonce">(function(){var w=window.__webui"#));
246        assert!(html.contains(r#"<meta name="host-head">"#));
247        assert!(html.contains(r#"<script src="host.js"></script>"#));
248    }
249
250    #[test]
251    fn serve_request_json_partial_carries_module_component_styles() {
252        let mut fragments = HashMap::new();
253        fragments.insert(
254            "index.html".to_string(),
255            FragmentList {
256                fragments: vec![WebUIFragment::component("my-page")],
257                contains_boundary: false,
258            },
259        );
260        fragments.insert(
261            "my-page".to_string(),
262            FragmentList {
263                fragments: vec![WebUIFragment::raw("<p>page</p>")],
264                contains_boundary: false,
265            },
266        );
267
268        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
269        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
270        let component = protocol
271            .components
272            .entry("my-page".to_string())
273            .or_default();
274        component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
275        component.css = ".page{color:red}".to_string();
276        add_page_style_closures(&mut protocol);
277        let protocol = Protocol::new(protocol);
278
279        let handler = WebUIHandler::new();
280        let request = ServeRequest::new(RenderOptions::new("index.html", "/"), true, "");
281
282        let response = serve_request(&protocol, &handler, json!({}), &request)
283            .expect("partial response should succeed");
284
285        let json = response_json(response);
286
287        assert_eq!(
288            json["componentStyles"]["resources"]["my-page"],
289            json!({
290                "kind": "module",
291                "specifier": "my-page",
292                "css": ".page{color:red}"
293            })
294        );
295        // templates must not contain any style tags
296        assert!(
297            !json["templates"]["my-page"]["h"]
298                .as_str()
299                .unwrap_or_default()
300                .contains("<style"),
301            "template metadata should not contain module style tags"
302        );
303    }
304
305    #[test]
306    fn serve_request_link_strategy_returns_component_styles() {
307        let mut fragments = HashMap::new();
308        fragments.insert(
309            "index.html".to_string(),
310            FragmentList {
311                fragments: vec![WebUIFragment::component("my-page")],
312                contains_boundary: false,
313            },
314        );
315        fragments.insert(
316            "my-page".to_string(),
317            FragmentList {
318                fragments: vec![WebUIFragment::raw("<p>page</p>")],
319                contains_boundary: false,
320            },
321        );
322
323        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
324        let comp = protocol
325            .components
326            .entry("my-page".to_string())
327            .or_default();
328        comp.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
329        comp.css_href = "my-page.css".to_string();
330        add_page_style_closures(&mut protocol);
331        let protocol = Protocol::new(protocol);
332
333        let handler = WebUIHandler::new();
334        let request = ServeRequest::new(RenderOptions::new("index.html", "/"), true, "");
335
336        let response = serve_request(&protocol, &handler, json!({}), &request)
337            .expect("partial response should succeed");
338
339        let json = response_json(response);
340
341        assert_eq!(
342            json["componentStyles"]["resources"]["my-page"],
343            json!({"kind": "link", "href": "my-page.css"})
344        );
345        assert!(
346            json["templates"].as_object().is_some_and(|a| a.len() == 1),
347            "Link strategy should still return templates"
348        );
349    }
350
351    #[test]
352    fn serve_request_style_strategy_returns_component_styles() {
353        let mut fragments = HashMap::new();
354        fragments.insert(
355            "index.html".to_string(),
356            FragmentList {
357                fragments: vec![WebUIFragment::component("my-page")],
358                contains_boundary: false,
359            },
360        );
361        fragments.insert(
362            "my-page".to_string(),
363            FragmentList {
364                fragments: vec![WebUIFragment::raw("<p>page</p>")],
365                contains_boundary: false,
366            },
367        );
368
369        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
370        protocol.set_css_strategy(CssStrategy::Style);
371        let comp = protocol
372            .components
373            .entry("my-page".to_string())
374            .or_default();
375        comp.template_json = r#"{"h":"<p/>"}"#.to_string();
376        comp.css = ".p{color:red}".to_string();
377        add_page_style_closures(&mut protocol);
378        let protocol = Protocol::new(protocol);
379
380        let handler = WebUIHandler::new();
381        let request = ServeRequest::new(RenderOptions::new("index.html", "/"), true, "");
382
383        let response = serve_request(&protocol, &handler, json!({}), &request)
384            .expect("partial response should succeed");
385
386        let json = response_json(response);
387
388        assert_eq!(
389            json["componentStyles"]["resources"]["my-page"],
390            json!({"kind": "style", "css": ".p{color:red}"})
391        );
392        assert!(
393            json["templates"].as_object().is_some_and(|a| a.len() == 1),
394            "Style strategy should still return templates"
395        );
396    }
397}