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 (key, value) in params {
115 map.insert(key, serde_json::Value::String(value));
116 }
117 }
118
119 if request.accept_json {
120 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 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 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}