1use crate::csp;
2use crate::types::{AssetManifest, PageConfig, PageOutput, RenderMode};
3use forma_ir as ir;
4
5struct ResolvedAssets {
7 fonts: Vec<String>,
8 css_urls: Vec<String>,
9 js_urls: Vec<String>,
10}
11
12fn escape_html(s: &str) -> String {
14 s.replace('&', "&")
15 .replace('<', "<")
16 .replace('>', ">")
17 .replace('"', """)
18}
19
20fn build_head(
21 title: &str,
22 nonce: &str,
23 assets: &ResolvedAssets,
24 personality_css: Option<&str>,
25) -> String {
26 let mut head = String::with_capacity(2048);
27 head.push_str("<meta charset=\"utf-8\">\n");
28 head.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
29 head.push_str(&format!("<title>{}</title>\n", escape_html(title)));
30
31 for font in &assets.fonts {
33 head.push_str(&format!(
34 "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n",
35 escape_html(font)
36 ));
37 }
38
39 for css in &assets.css_urls {
41 head.push_str(&format!(
42 "<link rel=\"stylesheet\" href=\"{}\">\n",
43 escape_html(css)
44 ));
45 }
46
47 for js in &assets.js_urls {
49 head.push_str(&format!(
50 "<link rel=\"modulepreload\" href=\"{}\">\n",
51 escape_html(js)
52 ));
53 }
54
55 if let Some(css) = personality_css {
58 let safe_css = css
59 .replace("</style>", "</style>")
60 .replace("</STYLE>", "</STYLE>");
61 head.push_str(&format!("<style nonce=\"{nonce}\">{safe_css}</style>\n"));
62 }
63
64 head
65}
66
67fn resolve_assets(manifest: &AssetManifest, route_pattern: &str) -> ResolvedAssets {
69 let route = manifest.route(route_pattern);
70 ResolvedAssets {
71 fonts: route
72 .map(|r| r.fonts.iter().map(|f| format!("/_assets/{f}")).collect())
73 .unwrap_or_default(),
74 css_urls: route
75 .map(|r| r.css.iter().map(|f| format!("/_assets/{f}")).collect())
76 .unwrap_or_default(),
77 js_urls: route
78 .map(|r| r.js.iter().map(|f| format!("/_assets/{f}")).collect())
79 .unwrap_or_default(),
80 }
81}
82
83struct BodyParts {
85 body_class_attr: String,
86 body_prefix: String,
87 config_script_tag: String,
88 page_js_tag: String,
89 wasm_script: String,
90 sw_script: String,
91}
92
93fn build_body_parts(nonce: &str, config: &PageConfig, js_urls: &[String]) -> BodyParts {
94 let body_class_attr = config
95 .body_class
96 .map(|c| format!(" class=\"{}\"", escape_html(c)))
97 .unwrap_or_default();
98 let body_prefix = config.body_prefix.unwrap_or("").to_string();
99 let config_script_tag = config
100 .config_script
101 .map(|s| format!("<script nonce=\"{nonce}\">{s}</script>\n"))
102 .unwrap_or_default();
103
104 let page_js_tag = js_urls
105 .last()
106 .map(|url| {
107 format!(
108 "<script type=\"module\" nonce=\"{nonce}\" src=\"{}\"></script>",
109 escape_html(url)
110 )
111 })
112 .unwrap_or_default();
113
114 let wasm_script = match (
115 &config.manifest.wasm,
116 config.manifest.route(config.route_pattern),
117 ) {
118 (Some(wasm), Some(route)) => match route.ir.as_ref() {
119 Some(ir_name) => format!(
120 "<script nonce=\"{nonce}\">window.__FORMA_WASM__={{loader:\"/_assets/{}\",binary:\"/_assets/{}\",ir:\"/_assets/{}\"}};</script>\n",
121 escape_html(&wasm.loader), escape_html(&wasm.binary), escape_html(ir_name)
122 ),
123 None => String::new(),
124 },
125 _ => String::new(),
126 };
127
128 let sw_script = format!(
129 "<script nonce=\"{nonce}\">if('serviceWorker' in navigator)navigator.serviceWorker.register('/sw.js');</script>\n"
130 );
131
132 BodyParts {
133 body_class_attr,
134 body_prefix,
135 config_script_tag,
136 page_js_tag,
137 wasm_script,
138 sw_script,
139 }
140}
141
142pub fn render_page(config: &PageConfig) -> PageOutput {
143 match config.render_mode {
144 RenderMode::Phase1ClientMount => render_page_phase1(config),
145 RenderMode::Phase2SsrReconcile => render_page_phase2(config),
146 }
147}
148
149fn render_page_phase1(config: &PageConfig) -> PageOutput {
151 let nonce = csp::generate_csp_nonce();
152 let assets = resolve_assets(config.manifest, config.route_pattern);
153 let head = build_head(config.title, &nonce, &assets, config.personality_css);
154 let parts = build_body_parts(&nonce, config, &assets.js_urls);
155
156 let html = format!(
157 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n{head}</head>\n<body{bc}>\n{bp}<div id=\"app\"></div>\n{cs}{pj}\n{wasm}{sw}</body>\n</html>",
158 bc = parts.body_class_attr,
159 bp = parts.body_prefix,
160 cs = parts.config_script_tag,
161 pj = parts.page_js_tag,
162 wasm = parts.wasm_script,
163 sw = parts.sw_script,
164 );
165
166 PageOutput {
167 html,
168 csp: csp::build_csp_header(&nonce),
169 }
170}
171
172fn render_page_phase2(config: &PageConfig) -> PageOutput {
175 let (ir_module, slots) = match (config.ir_module, config.slots) {
177 (Some(m), Some(s)) => (m, s),
178 _ => {
179 tracing::warn!(
180 route = config.route_pattern,
181 "Phase 2 SSR: missing IR module or slots, falling back to Phase 1"
182 );
183 return render_page_phase1(config);
184 }
185 };
186
187 let ssr_body = match ir::walker::walk_to_html(ir_module, slots) {
189 Ok(html) => html,
190 Err(err) => {
191 tracing::warn!(
192 route = config.route_pattern,
193 error = %err,
194 "Phase 2 SSR: IR walk failed, falling back to Phase 1"
195 );
196 return render_page_phase1(config);
197 }
198 };
199
200 let nonce = csp::generate_csp_nonce();
202 let assets = resolve_assets(config.manifest, config.route_pattern);
203 let head = build_head(config.title, &nonce, &assets, config.personality_css);
204 let parts = build_body_parts(&nonce, config, &assets.js_urls);
205
206 let html = format!(
207 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n{head}</head>\n<body{bc}>\n{bp}<div id=\"app\" data-forma-ssr>{ssr}</div>\n{cs}{pj}\n{wasm}{sw}</body>\n</html>",
208 bc = parts.body_class_attr,
209 bp = parts.body_prefix,
210 ssr = ssr_body,
211 cs = parts.config_script_tag,
212 pj = parts.page_js_tag,
213 wasm = parts.wasm_script,
214 sw = parts.sw_script,
215 );
216
217 PageOutput {
218 html,
219 csp: csp::build_csp_header(&nonce),
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use crate::types::{RouteAssets, WasmAssets};
227 use forma_ir::parser::IrModule;
228 use forma_ir::slot::{SlotData, SlotValue};
229 use std::collections::HashMap;
230
231 fn empty_manifest() -> AssetManifest {
233 AssetManifest {
234 version: 1,
235 build_hash: "test".to_string(),
236 assets: HashMap::new(),
237 routes: HashMap::new(),
238 wasm: None,
239 }
240 }
241
242 fn hello_ir_module() -> IrModule {
244 use forma_ir::parser::test_helpers::{
245 build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
246 };
247
248 let mut opcodes = Vec::new();
249 opcodes.extend_from_slice(&encode_open_tag(0, &[])); opcodes.extend_from_slice(&encode_text(1)); opcodes.extend_from_slice(&encode_close_tag(0)); let data = build_minimal_ir(&["p", "hello"], &[], &opcodes, &[]);
254 IrModule::parse(&data).unwrap()
255 }
256
257 #[test]
258 fn phase1_renders_empty_app_div() {
259 let manifest = empty_manifest();
260 let page = render_page(&PageConfig {
261 title: "Test",
262 route_pattern: "/test",
263 manifest: &manifest,
264 config_script: None,
265 body_class: None,
266 personality_css: None,
267 body_prefix: None,
268 render_mode: RenderMode::Phase1ClientMount,
269 ir_module: None,
270 slots: None,
271 });
272
273 assert!(page.html.contains("<div id=\"app\"></div>"));
274 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
276 }
277
278 #[test]
279 fn phase2_renders_ssr_content_with_data_attr() {
280 let manifest = empty_manifest();
281 let ir = hello_ir_module();
282 let slots = SlotData::new(0);
283
284 let page = render_page(&PageConfig {
285 title: "SSR Test",
286 route_pattern: "/test",
287 manifest: &manifest,
288 config_script: None,
289 body_class: None,
290 personality_css: None,
291 body_prefix: None,
292 render_mode: RenderMode::Phase2SsrReconcile,
293 ir_module: Some(&ir),
294 slots: Some(&slots),
295 });
296
297 assert!(page.html.contains("data-forma-ssr"));
298 assert!(page.html.contains("<p>hello</p>"));
299 assert!(page.html.contains("<div id=\"app\" data-forma-ssr>"));
300 }
301
302 #[test]
303 fn phase2_falls_back_to_phase1_when_ir_module_missing() {
304 let manifest = empty_manifest();
305
306 let page = render_page(&PageConfig {
307 title: "Fallback Test",
308 route_pattern: "/test",
309 manifest: &manifest,
310 config_script: None,
311 body_class: None,
312 personality_css: None,
313 body_prefix: None,
314 render_mode: RenderMode::Phase2SsrReconcile,
315 ir_module: None,
316 slots: None,
317 });
318
319 assert!(page.html.contains("<div id=\"app\"></div>"));
321 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
322 }
323
324 #[test]
325 fn phase2_falls_back_to_phase1_when_slots_missing() {
326 let manifest = empty_manifest();
327 let ir = hello_ir_module();
328
329 let page = render_page(&PageConfig {
330 title: "Fallback Test",
331 route_pattern: "/test",
332 manifest: &manifest,
333 config_script: None,
334 body_class: None,
335 personality_css: None,
336 body_prefix: None,
337 render_mode: RenderMode::Phase2SsrReconcile,
338 ir_module: Some(&ir),
339 slots: None,
340 });
341
342 assert!(page.html.contains("<div id=\"app\"></div>"));
343 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
344 }
345
346 #[test]
347 fn phase2_falls_back_on_ir_walk_error() {
348 let manifest = empty_manifest();
349 let data = forma_ir::parser::test_helpers::build_minimal_ir(
351 &["x"],
352 &[],
353 &[0xFF], &[],
355 );
356 let ir = IrModule::parse(&data).unwrap();
357 let slots = SlotData::new(0);
358
359 let page = render_page(&PageConfig {
360 title: "Walk Error Test",
361 route_pattern: "/test",
362 manifest: &manifest,
363 config_script: None,
364 body_class: None,
365 personality_css: None,
366 body_prefix: None,
367 render_mode: RenderMode::Phase2SsrReconcile,
368 ir_module: Some(&ir),
369 slots: Some(&slots),
370 });
371
372 assert!(page.html.contains("<div id=\"app\"></div>"));
374 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
375 }
376
377 #[test]
378 fn phase2_ssr_preserves_head_content() {
379 let manifest = empty_manifest();
380 let ir = hello_ir_module();
381 let slots = SlotData::new(0);
382
383 let page = render_page(&PageConfig {
384 title: "Head Test",
385 route_pattern: "/test",
386 manifest: &manifest,
387 config_script: Some("window.__TEST__=true;"),
388 body_class: Some("dark"),
389 personality_css: Some(":root{--c:red}"),
390 body_prefix: Some("<nav>nav</nav>"),
391 render_mode: RenderMode::Phase2SsrReconcile,
392 ir_module: Some(&ir),
393 slots: Some(&slots),
394 });
395
396 assert!(page.html.contains("<title>Head Test</title>"));
397 assert!(page.html.contains("window.__TEST__=true;"));
398 assert!(page.html.contains("class=\"dark\""));
399 assert!(page.html.contains(":root{--c:red}"));
400 assert!(page.html.contains("<nav>nav</nav>"));
401 assert!(page.html.contains("<p>hello</p>"));
402 assert!(page.html.contains("data-forma-ssr"));
403 }
404
405 #[test]
406 fn wasm_script_injected_when_manifest_has_wasm_and_route_has_ir() {
407 let mut routes = HashMap::new();
408 routes.insert(
409 "/test".to_string(),
410 RouteAssets {
411 js: vec!["test.abc123.js".to_string()],
412 css: vec![],
413 fonts: vec![],
414 total_size_br: 0,
415 budget_warn_threshold: 204800,
416 ir: Some("test.def456.ir".to_string()),
417 },
418 );
419
420 let manifest = AssetManifest {
421 version: 1,
422 build_hash: "test".to_string(),
423 assets: HashMap::new(),
424 routes,
425 wasm: Some(WasmAssets {
426 loader: "forma_ir.abc.js".to_string(),
427 binary: "forma_ir_bg.def.wasm".to_string(),
428 }),
429 };
430
431 let page = render_page(&PageConfig {
432 title: "WASM Test",
433 route_pattern: "/test",
434 manifest: &manifest,
435 config_script: None,
436 body_class: None,
437 personality_css: None,
438 body_prefix: None,
439 render_mode: RenderMode::Phase1ClientMount,
440 ir_module: None,
441 slots: None,
442 });
443
444 assert!(page.html.contains("__FORMA_WASM__"));
445 assert!(page.html.contains("/_assets/forma_ir.abc.js"));
446 assert!(page.html.contains("/_assets/forma_ir_bg.def.wasm"));
447 assert!(page.html.contains("/_assets/test.def456.ir"));
448 }
449
450 #[test]
451 fn wasm_script_not_injected_when_no_wasm_in_manifest() {
452 let manifest = empty_manifest();
453 let page = render_page(&PageConfig {
454 title: "No WASM",
455 route_pattern: "/test",
456 manifest: &manifest,
457 config_script: None,
458 body_class: None,
459 personality_css: None,
460 body_prefix: None,
461 render_mode: RenderMode::Phase1ClientMount,
462 ir_module: None,
463 slots: None,
464 });
465
466 assert!(!page.html.contains("__FORMA_WASM__"));
467 }
468
469 #[test]
470 fn wasm_script_not_injected_when_route_has_no_ir() {
471 let mut routes = HashMap::new();
472 routes.insert(
473 "/test".to_string(),
474 RouteAssets {
475 js: vec!["test.abc123.js".to_string()],
476 css: vec![],
477 fonts: vec![],
478 total_size_br: 0,
479 budget_warn_threshold: 204800,
480 ir: None, },
482 );
483
484 let manifest = AssetManifest {
485 version: 1,
486 build_hash: "test".to_string(),
487 assets: HashMap::new(),
488 routes,
489 wasm: Some(WasmAssets {
490 loader: "forma_ir.abc.js".to_string(),
491 binary: "forma_ir_bg.def.wasm".to_string(),
492 }),
493 };
494
495 let page = render_page(&PageConfig {
496 title: "No IR",
497 route_pattern: "/test",
498 manifest: &manifest,
499 config_script: None,
500 body_class: None,
501 personality_css: None,
502 body_prefix: None,
503 render_mode: RenderMode::Phase1ClientMount,
504 ir_module: None,
505 slots: None,
506 });
507
508 assert!(!page.html.contains("__FORMA_WASM__"));
509 }
510
511 #[test]
512 fn phase2_with_slot_data() {
513 use forma_ir::parser::test_helpers::{build_minimal_ir, encode_close_tag, encode_open_tag};
514
515 let mut opcodes = Vec::new();
517 opcodes.extend_from_slice(&encode_open_tag(0, &[])); opcodes.push(0x05);
520 opcodes.extend_from_slice(&0u16.to_le_bytes()); opcodes.extend_from_slice(&0u16.to_le_bytes()); opcodes.extend_from_slice(&encode_close_tag(0)); let data = build_minimal_ir(
525 &["span", "name"], &[(0, 1, 0x01, 0x00, &[])], &opcodes,
528 &[],
529 );
530 let ir = IrModule::parse(&data).unwrap();
531
532 let mut slots = SlotData::new(1);
533 slots.set(0, SlotValue::Text("World".to_string()));
534
535 let manifest = empty_manifest();
536 let page = render_page(&PageConfig {
537 title: "Slot Test",
538 route_pattern: "/test",
539 manifest: &manifest,
540 config_script: None,
541 body_class: None,
542 personality_css: None,
543 body_prefix: None,
544 render_mode: RenderMode::Phase2SsrReconcile,
545 ir_module: Some(&ir),
546 slots: Some(&slots),
547 });
548
549 assert!(page.html.contains("data-forma-ssr"));
550 assert!(page
552 .html
553 .contains("<span><!--f:t0-->World<!--/f:t0--></span>"));
554 }
555
556 #[test]
559 fn escape_html_basic_entities() {
560 assert_eq!(escape_html("<script>"), "<script>");
561 assert_eq!(escape_html("\"quotes\""), ""quotes"");
562 assert_eq!(escape_html("a&b"), "a&b");
563 assert_eq!(escape_html("clean"), "clean");
564 }
565
566 #[test]
567 fn asset_filename_xss_is_escaped() {
568 let mut routes = HashMap::new();
571 routes.insert(
572 "/test".to_string(),
573 RouteAssets {
574 js: vec!["\"><script>alert(1)</script>".to_string()],
575 css: vec!["\"><script>alert(2)</script>".to_string()],
576 fonts: vec!["\"><script>alert(3)</script>".to_string()],
577 total_size_br: 0,
578 budget_warn_threshold: 204800,
579 ir: None,
580 },
581 );
582 let manifest = AssetManifest {
583 version: 1,
584 build_hash: "test".to_string(),
585 assets: HashMap::new(),
586 routes,
587 wasm: None,
588 };
589
590 let page = render_page(&PageConfig {
591 title: "XSS Test",
592 route_pattern: "/test",
593 manifest: &manifest,
594 config_script: None,
595 body_class: None,
596 personality_css: None,
597 body_prefix: None,
598 render_mode: RenderMode::Phase1ClientMount,
599 ir_module: None,
600 slots: None,
601 });
602
603 assert!(
605 !page.html.contains("<script>alert("),
606 "asset filenames must be HTML-escaped, got: {}",
607 page.html
608 );
609 assert!(
610 page.html.contains("<script>"),
611 "escaped <script> should be present"
612 );
613 }
614
615 #[test]
616 fn body_class_with_quotes_is_escaped() {
617 let manifest = empty_manifest();
618 let page = render_page(&PageConfig {
619 title: "Body Class Test",
620 route_pattern: "/test",
621 manifest: &manifest,
622 config_script: None,
623 body_class: Some("dark\" onload=\"alert(1)"),
624 personality_css: None,
625 body_prefix: None,
626 render_mode: RenderMode::Phase1ClientMount,
627 ir_module: None,
628 slots: None,
629 });
630
631 assert!(
636 page.html.contains("""),
637 "body_class double quote must be escaped"
638 );
639 assert!(
640 !page.html.contains(r#"class="dark" onload="#),
641 "body_class must not allow attribute breakout via unescaped double quote"
642 );
643 }
644
645 #[test]
646 fn personality_css_style_breakout_prevented() {
647 let manifest = empty_manifest();
648 let page = render_page(&PageConfig {
649 title: "CSS Test",
650 route_pattern: "/test",
651 manifest: &manifest,
652 config_script: None,
653 body_class: None,
654 personality_css: Some("body{color:red}</style><script>alert(1)</script>"),
655 body_prefix: None,
656 render_mode: RenderMode::Phase1ClientMount,
657 ir_module: None,
658 slots: None,
659 });
660
661 assert!(
663 !page.html.contains("</style><script>alert"),
664 "personality_css must not allow </style> breakout"
665 );
666 }
667}