1use crate::{Protocol, WebUIHandler};
32use webui_handler::route_handler;
33use webui_handler::RenderOptions;
34
35webui_handler::define_string_response_writer!(MemWriter, buf);
36
37pub struct ServeRequest<'a> {
39 render_options: RenderOptions<'a>,
40 accept_json: bool,
41 inventory_hex: &'a str,
42}
43
44impl<'a> ServeRequest<'a> {
45 #[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
65pub enum ServeResponse {
67 Html(String),
69 Json(String),
71}
72
73pub 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 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 (k, v) in ¶ms {
115 map.insert(k.clone(), serde_json::Value::String(v.clone()));
116 }
117 }
118
119 if request.accept_json {
120 let state_json =
122 serde_json::to_string(&data).map_err(|e| format!("state serialization failed: {e}"))?;
123 let partial = protocol
124 .render_partial(
125 &state_json,
126 options.entry_id,
127 options.request_path,
128 request.inventory_hex,
129 )
130 .map_err(|e| format!("render_partial failed: {e}"))?;
131 Ok(ServeResponse::Json(partial))
132 } else {
133 let mut writer = MemWriter::with_capacity(131_072);
136 handler
137 .render(protocol, &data, options, &mut writer)
138 .map_err(|e| format!("render failed: {e}"))?;
139
140 Ok(ServeResponse::Html(writer.buf))
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use serde_json::json;
148 use std::collections::HashMap;
149 use webui_handler::plugin::webui::WebUIHydrationPlugin;
150 use webui_protocol::{
151 ComponentData, ComponentStyleClosure, CssStrategy, FragmentList, WebUIFragment,
152 WebUIProtocol,
153 };
154
155 fn structural_fragment(value: &str) -> WebUIFragment {
156 let mut signal = String::with_capacity("}}}webui:".len() + value.len());
157 signal.push_str("}}}webui:");
158 signal.push_str(value);
159 WebUIFragment::signal(signal, true)
160 }
161
162 fn add_page_style_closures(protocol: &mut WebUIProtocol) {
163 let closure = ComponentStyleClosure {
164 component_tags: vec!["my-page".to_string()],
165 style_chunks: Vec::new(),
166 };
167 protocol
168 .style_closures
169 .insert("index.html".to_string(), closure.clone());
170 protocol
171 .style_closures
172 .insert("my-page".to_string(), closure);
173 }
174
175 fn response_json(response: ServeResponse) -> serde_json::Value {
176 match response {
177 ServeResponse::Json(json) => {
178 serde_json::from_str(&json).expect("partial response should be valid JSON")
179 }
180 ServeResponse::Html(_) => panic!("expected JSON partial response"),
181 }
182 }
183
184 fn full_document_protocol() -> Protocol {
185 let fragments = HashMap::from([
186 (
187 "index.html".to_string(),
188 FragmentList {
189 fragments: vec![
190 WebUIFragment::raw("<!doctype html><html><head>"),
191 structural_fragment("head_end"),
192 WebUIFragment::raw("</head><body>"),
193 WebUIFragment::component("my-page"),
194 structural_fragment("body_end"),
195 WebUIFragment::raw("</body></html>"),
196 ],
197 contains_boundary: false,
198 },
199 ),
200 (
201 "my-page".to_string(),
202 FragmentList {
203 fragments: vec![WebUIFragment::raw("<main>ready</main>")],
204 contains_boundary: false,
205 },
206 ),
207 ]);
208 let mut protocol = WebUIProtocol::new(fragments);
209 protocol.components.insert(
210 "my-page".to_string(),
211 ComponentData {
212 template_json: r#"{"h":"<main>ready</main>","th":1}"#.to_string(),
213 template_functions: "[function(){return true}]".to_string(),
214 ..Default::default()
215 },
216 );
217 Protocol::new(protocol)
218 }
219
220 #[test]
221 fn serve_request_html_forwards_complete_render_options() {
222 let protocol = full_document_protocol();
223 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
224 let request = ServeRequest::new(
225 RenderOptions::new("index.html", "/")
226 .with_nonce("request-nonce")
227 .with_head_inject("<meta name=\"host-head\">")
228 .with_body_inject("<script src=\"host.js\"></script>"),
229 false,
230 "",
231 );
232
233 let response = serve_request(&protocol, &handler, json!({}), &request)
234 .expect("full-document response should succeed");
235 let html = match response {
236 ServeResponse::Html(html) => html,
237 ServeResponse::Json(_) => panic!("expected full-document response"),
238 };
239
240 let mut direct = MemWriter::with_capacity(131_072);
241 handler
242 .render(&protocol, &json!({}), &request.render_options, &mut direct)
243 .expect("direct render should succeed");
244
245 assert_eq!(html, direct.buf);
246 assert!(html.contains(r#"<meta name="webui-nonce" content="request-nonce">"#));
247 assert!(html.contains(r#"<script nonce="request-nonce">(function(){var w=window.__webui"#));
248 assert!(html.contains(r#"<meta name="host-head">"#));
249 assert!(html.contains(r#"<script src="host.js"></script>"#));
250 }
251
252 #[test]
253 fn serve_request_json_partial_carries_module_component_styles() {
254 let mut fragments = HashMap::new();
255 fragments.insert(
256 "index.html".to_string(),
257 FragmentList {
258 fragments: vec![WebUIFragment::component("my-page")],
259 contains_boundary: false,
260 },
261 );
262 fragments.insert(
263 "my-page".to_string(),
264 FragmentList {
265 fragments: vec![WebUIFragment::raw("<p>page</p>")],
266 contains_boundary: false,
267 },
268 );
269
270 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
271 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
272 let component = protocol
273 .components
274 .entry("my-page".to_string())
275 .or_default();
276 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
277 component.css = ".page{color:red}".to_string();
278 add_page_style_closures(&mut protocol);
279 let protocol = Protocol::new(protocol);
280
281 let handler = WebUIHandler::new();
282 let request = ServeRequest::new(RenderOptions::new("index.html", "/"), true, "");
283
284 let response = serve_request(&protocol, &handler, json!({}), &request)
285 .expect("partial response should succeed");
286
287 let json = response_json(response);
288
289 assert_eq!(
290 json["componentStyles"]["resources"]["my-page"],
291 json!({
292 "kind": "module",
293 "specifier": "my-page",
294 "css": ".page{color:red}"
295 })
296 );
297 assert!(
299 !json["templates"]["my-page"]["h"]
300 .as_str()
301 .unwrap_or_default()
302 .contains("<style"),
303 "template metadata should not contain module style tags"
304 );
305 }
306
307 #[test]
308 fn serve_request_link_strategy_returns_component_styles() {
309 let mut fragments = HashMap::new();
310 fragments.insert(
311 "index.html".to_string(),
312 FragmentList {
313 fragments: vec![WebUIFragment::component("my-page")],
314 contains_boundary: false,
315 },
316 );
317 fragments.insert(
318 "my-page".to_string(),
319 FragmentList {
320 fragments: vec![WebUIFragment::raw("<p>page</p>")],
321 contains_boundary: false,
322 },
323 );
324
325 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
326 let comp = protocol
327 .components
328 .entry("my-page".to_string())
329 .or_default();
330 comp.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
331 comp.css_href = "my-page.css".to_string();
332 add_page_style_closures(&mut protocol);
333 let protocol = Protocol::new(protocol);
334
335 let handler = WebUIHandler::new();
336 let request = ServeRequest::new(RenderOptions::new("index.html", "/"), true, "");
337
338 let response = serve_request(&protocol, &handler, json!({}), &request)
339 .expect("partial response should succeed");
340
341 let json = response_json(response);
342
343 assert_eq!(
344 json["componentStyles"]["resources"]["my-page"],
345 json!({"kind": "link", "href": "my-page.css"})
346 );
347 assert!(
348 json["templates"].as_object().is_some_and(|a| a.len() == 1),
349 "Link strategy should still return templates"
350 );
351 }
352
353 #[test]
354 fn serve_request_style_strategy_returns_component_styles() {
355 let mut fragments = HashMap::new();
356 fragments.insert(
357 "index.html".to_string(),
358 FragmentList {
359 fragments: vec![WebUIFragment::component("my-page")],
360 contains_boundary: false,
361 },
362 );
363 fragments.insert(
364 "my-page".to_string(),
365 FragmentList {
366 fragments: vec![WebUIFragment::raw("<p>page</p>")],
367 contains_boundary: false,
368 },
369 );
370
371 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
372 protocol.set_css_strategy(CssStrategy::Style);
373 let comp = protocol
374 .components
375 .entry("my-page".to_string())
376 .or_default();
377 comp.template_json = r#"{"h":"<p/>"}"#.to_string();
378 comp.css = ".p{color:red}".to_string();
379 add_page_style_closures(&mut protocol);
380 let protocol = Protocol::new(protocol);
381
382 let handler = WebUIHandler::new();
383 let request = ServeRequest::new(RenderOptions::new("index.html", "/"), true, "");
384
385 let response = serve_request(&protocol, &handler, json!({}), &request)
386 .expect("partial response should succeed");
387
388 let json = response_json(response);
389
390 assert_eq!(
391 json["componentStyles"]["resources"]["my-page"],
392 json!({"kind": "style", "css": ".p{color:red}"})
393 );
394 assert!(
395 json["templates"].as_object().is_some_and(|a| a.len() == 1),
396 "Style strategy should still return templates"
397 );
398 }
399}