Skip to main content

forma_server/
template.rs

1use crate::csp;
2use crate::types::{AssetManifest, PageConfig, PageOutput, RenderMode};
3use forma_ir as ir;
4
5/// Resolved asset URLs for a route — shared between Phase 1 and Phase 2 paths.
6struct ResolvedAssets {
7    fonts: Vec<String>,
8    css_urls: Vec<String>,
9    js_urls: Vec<String>,
10}
11
12/// Build the common `<head>` content shared by both render paths.
13fn escape_html(s: &str) -> String {
14    s.replace('&', "&amp;")
15        .replace('<', "&lt;")
16        .replace('>', "&gt;")
17        .replace('"', "&quot;")
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    // Font preloads
32    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    // CSS stylesheets
40    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    // JS modulepreloads
48    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    // Personality CSS (inline, small)
56    // Escape "</style>" sequences to prevent breaking out of the style block.
57    if let Some(css) = personality_css {
58        let safe_css = css
59            .replace("</style>", "&lt;/style&gt;")
60            .replace("</STYLE>", "&lt;/STYLE&gt;");
61        head.push_str(&format!("<style nonce=\"{nonce}\">{safe_css}</style>\n"));
62    }
63
64    head
65}
66
67/// Resolve asset URLs from the manifest for a given route pattern.
68fn 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
83/// Build shared body fragments (class attr, prefix, config script, page JS tag).
84struct 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
149/// Phase 1: empty `<div id="app"></div>` — client JS mounts from scratch.
150fn 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
172/// Phase 2: server-render the IR module into `<div id="app" data-forma-ssr>`.
173/// Falls back to Phase 1 on any error (missing IR module, walk failure, etc.).
174fn render_page_phase2(config: &PageConfig) -> PageOutput {
175    // Both ir_module and slots must be present for SSR
176    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    // Attempt IR walk
188    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    // SSR succeeded — build the full page with SSR content
201    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    /// Build a minimal AssetManifest with no routes for testing.
232    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    /// Build a valid IR module that renders "<p>hello</p>".
243    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, &[])); // <p>
250        opcodes.extend_from_slice(&encode_text(1)); // hello
251        opcodes.extend_from_slice(&encode_close_tag(0)); // </p>
252
253        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        // Check that the #app div does NOT have the SSR attribute
275        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        // Should fall back to Phase 1: empty #app, no data-forma-ssr
320        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        // Build an IR module with deliberately corrupt opcodes
350        let data = forma_ir::parser::test_helpers::build_minimal_ir(
351            &["x"],
352            &[],
353            &[0xFF], // invalid opcode byte
354            &[],
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        // Should fall back to Phase 1
373        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, // no IR
481            },
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        // Build IR with DYN_TEXT referencing slot 0
516        let mut opcodes = Vec::new();
517        opcodes.extend_from_slice(&encode_open_tag(0, &[])); // <span>
518                                                             // DYN_TEXT opcode: 0x05 + slot_id(u16) + marker_id(u16)
519        opcodes.push(0x05);
520        opcodes.extend_from_slice(&0u16.to_le_bytes()); // slot_id = 0
521        opcodes.extend_from_slice(&0u16.to_le_bytes()); // marker_id = 0
522        opcodes.extend_from_slice(&encode_close_tag(0)); // </span>
523
524        let data = build_minimal_ir(
525            &["span", "name"],          // strings
526            &[(0, 1, 0x01, 0x00, &[])], // slot: id=0, name_str_idx=1, type=Text, source=Server
527            &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        // DYN_TEXT wraps content in marker comments for client reconciliation
551        assert!(page
552            .html
553            .contains("<span><!--f:t0-->World<!--/f:t0--></span>"));
554    }
555
556    // -- Security: escape_html tests -----------------------------------------
557
558    #[test]
559    fn escape_html_basic_entities() {
560        assert_eq!(escape_html("<script>"), "&lt;script&gt;");
561        assert_eq!(escape_html("\"quotes\""), "&quot;quotes&quot;");
562        assert_eq!(escape_html("a&b"), "a&amp;b");
563        assert_eq!(escape_html("clean"), "clean");
564    }
565
566    #[test]
567    fn asset_filename_xss_is_escaped() {
568        // If a manifest somehow contained a malicious filename, it must be escaped
569        // in the HTML output rather than injected raw.
570        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        // The raw "<script>" must not appear unescaped in the output
604        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("&lt;script&gt;"),
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        // The double quote in body_class must be escaped so the attacker
632        // cannot break out of the class="..." attribute.
633        // Raw: class="dark" onload="alert(1)"  (attribute injection)
634        // Safe: class="dark&quot; onload=&quot;alert(1)"  (single quoted attr value)
635        assert!(
636            page.html.contains("&quot;"),
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        // The raw </style> breakout must be neutralized
662        assert!(
663            !page.html.contains("</style><script>alert"),
664            "personality_css must not allow </style> breakout"
665        );
666    }
667}