Skip to main content

forma_server/
pages.rs

1//! Startup binding between declared TypeScript page props and a derived Rust handler.
2use crate::{
3    AssetManifest, IrLoadError, Limits, PageConfig, PageResponse, RenderCtx, RenderError, RouteMode,
4};
5use forma_ir::{
6    format::{SlotSource, SlotType},
7    props::{BoundProps, PageProps, PropsError, PropsSource, PropsWarning},
8    schema::PropType,
9    slot::{json_to_slot_value, SlotData},
10    IrModule,
11};
12use rust_embed::Embed;
13use std::{
14    collections::{HashMap, HashSet},
15    fmt,
16    marker::PhantomData,
17    sync::{Arc, Mutex},
18};
19
20type GetModule = dyn Fn(&str) -> Option<(Arc<IrModule>, Arc<AssetManifest>)> + Send + Sync;
21
22/// Strictly loaded pages. Bind each server-rendered handler before opening a listener.
23/// Browser-only routes cannot produce a typed SSR handle.
24#[derive(Clone)]
25pub struct Pages {
26    manifest: Arc<AssetManifest>,
27    get: Arc<GetModule>,
28    seal: Arc<dyn Fn() -> Result<(), String> + Send + Sync>,
29    registration: Arc<Mutex<Registration>>,
30}
31#[derive(Default)]
32struct Registration {
33    bound: HashSet<String>,
34    finalized: bool,
35}
36
37impl fmt::Debug for Pages {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        f.debug_struct("Pages")
40            .field("routes", &self.manifest.routes.keys())
41            .finish_non_exhaustive()
42    }
43}
44impl Pages {
45    pub(crate) fn load<A: Embed + 'static>(
46        manifest: &AssetManifest,
47    ) -> Result<Self, Vec<IrLoadError>> {
48        let modules = Arc::new(crate::ir::IrModules::<A>::load(manifest)?);
49        let cache = Arc::clone(&modules);
50        Ok(Self {
51            manifest: Arc::new(manifest.clone()),
52            get: Arc::new(move |route| modules.snapshot(route)),
53            seal: Arc::new(move || cache.freeze_topology()),
54            registration: Arc::new(Mutex::new(Registration::default())),
55        })
56    }
57
58    /// Bind a route pattern or a logical page name (`home` resolves the manifest's
59    /// `home.ir` alias). Names matching multiple routes are refused: bind the exact
60    /// route in that case. Comparison and root-slot validation happen here, once.
61    pub fn bind<P: PageProps>(&self, page: &str) -> Result<PageHandle<P>, Vec<PropsError>> {
62        self.bind_with(page, Limits::default())
63    }
64
65    /// Bind this page with immutable defaults inherited by each request.
66    pub fn bind_with<P: PageProps>(
67        &self,
68        page: &str,
69        limits: Limits,
70    ) -> Result<PageHandle<P>, Vec<PropsError>> {
71        if self.is_finalized() {
72            return Err(vec![error(
73                "BIND-111",
74                page,
75                "",
76                "page registry is already finalized",
77            )]);
78        }
79        let route = self.resolve(page).map_err(|e| vec![e])?;
80        let (module, _) = (self.get)(&route).ok_or_else(|| {
81            vec![error(
82                "BIND-108",
83                &route,
84                "",
85                "page is browser-only or has no current SSR module",
86            )]
87        })?;
88        let binding = Binding::new(module, &route)?;
89        let mut registration = self.registration.lock().unwrap_or_else(|p| p.into_inner());
90        if registration.finalized {
91            return Err(vec![error(
92                "BIND-111",
93                &route,
94                "",
95                "page registry is already finalized",
96            )]);
97        }
98        for warning in binding.props.warnings() {
99            tracing::warn!(code = warning.code, route = %route, path = %warning.path, "{warning}");
100        }
101        registration.bound.insert(route.clone());
102        Ok(PageHandle {
103            inner: Arc::new(Handle {
104                page: page.into(),
105                limits,
106                route,
107                pages: self.clone(),
108                binding: Mutex::new(Arc::new(binding)),
109            }),
110            marker: PhantomData,
111        })
112    }
113
114    /// Complete startup after every SSR route has a verified handler. Call this
115    /// before the application opens its listener. A refusal leaves registration
116    /// open so missing handlers can be bound; success seals every clone together.
117    /// Browser routes require no binding. Repeated successful calls are harmless.
118    pub fn finalize(&self) -> Result<(), Vec<PropsError>> {
119        let mut registration = self.registration.lock().unwrap_or_else(|p| p.into_inner());
120        if registration.finalized {
121            return Ok(());
122        }
123        let mut errors: Vec<_> = self.manifest.routes.iter()
124            .filter(|(route, assets)| assets.mode == RouteMode::Ssr && !registration.bound.contains(*route))
125            .map(|(route, assets)| error("BIND-110", route, "", format!("SSR module {} has no verified handler; bind it before opening the listener", assets.ir.as_deref().unwrap_or("<missing>"))))
126            .collect();
127        errors.sort_by(|a, b| a.route.cmp(&b.route));
128        if !errors.is_empty() {
129            return Err(errors);
130        }
131        (self.seal)().map_err(|message| vec![error("BIND-112", "<manifest>", "", message)])?;
132        registration.finalized = true;
133        Ok(())
134    }
135
136    fn is_finalized(&self) -> bool {
137        self.registration
138            .lock()
139            .unwrap_or_else(|p| p.into_inner())
140            .finalized
141    }
142
143    fn resolve(&self, page: &str) -> Result<String, PropsError> {
144        if let Some(route) = self.manifest.routes.get(page) {
145            return if route.mode == RouteMode::Browser {
146                Err(error(
147                    "BIND-108",
148                    page,
149                    "",
150                    "browser-only route cannot bind server props",
151                ))
152            } else {
153                Ok(page.into())
154            };
155        }
156        let logical = format!("{page}.ir");
157        let filename = self
158            .manifest
159            .aliases
160            .get(&logical)
161            .map(String::as_str)
162            .unwrap_or(&logical);
163        let mut routes: Vec<_> = self
164            .manifest
165            .routes
166            .iter()
167            .filter(|(_, assets)| assets.ir.as_deref() == Some(filename))
168            .map(|(route, _)| route.clone())
169            .collect();
170        routes.sort();
171        match routes.len() {
172            0 => Err(error(
173                "BIND-107",
174                page,
175                "",
176                "unknown page; use a route pattern or the manifest's logical .ir alias",
177            )),
178            1 => Ok(routes.remove(0)),
179            _ => Err(error(
180                "BIND-109",
181                page,
182                "",
183                format!(
184                    "page name is ambiguous across {}; bind an exact route",
185                    routes.join(", ")
186                ),
187            )),
188        }
189    }
190}
191
192struct Binding<P: PageProps> {
193    props: BoundProps<P>,
194    roots: Vec<(String, u16)>,
195}
196impl<P: PageProps> Binding<P> {
197    fn new(module: Arc<IrModule>, route: &str) -> Result<Self, Vec<PropsError>> {
198        let props = BoundProps::<P>::bind(module, route)?;
199        let roots = root_slots(props.module(), route)?;
200        Ok(Self { props, roots })
201    }
202}
203struct Handle<P: PageProps> {
204    limits: Limits,
205    page: String,
206    route: String,
207    pages: Pages,
208    binding: Mutex<Arc<Binding<P>>>,
209}
210
211/// A verified handler contract. Clone it into application state; no constructor
212/// accepts raw slots or an unverified module. Request values are checked before any
213/// HTML is returned. In debug builds a changed module is bound again; an invalid
214/// file keeps the loader's last good module, while a valid incompatible schema
215/// returns `RENDER-405` until the handler or page is corrected.
216pub struct PageHandle<P: PageProps> {
217    inner: Arc<Handle<P>>,
218    marker: PhantomData<fn() -> P>,
219}
220impl<P: PageProps> Clone for PageHandle<P> {
221    fn clone(&self) -> Self {
222        Self {
223            inner: Arc::clone(&self.inner),
224            marker: PhantomData,
225        }
226    }
227}
228impl<P: PageProps> fmt::Debug for PageHandle<P> {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        f.debug_struct("PageHandle")
231            .field("page", &self.inner.page)
232            .field("route", &self.inner.route)
233            .finish_non_exhaustive()
234    }
235}
236impl<P: PageProps> PageHandle<P> {
237    /// The exact route pattern associated with this handle.
238    pub fn route(&self) -> &str {
239        &self.inner.route
240    }
241    /// The page defaults selected at bind. A request override does not change them.
242    pub fn limits(&self) -> Limits {
243        self.inner.limits
244    }
245
246    /// Extra Rust fields reported by the currently verified contract. They are
247    /// omitted from encoded page data, even when their values are unsafe.
248    pub fn warnings(&self) -> Vec<PropsWarning> {
249        self.inner
250            .binding
251            .lock()
252            .unwrap_or_else(|p| p.into_inner())
253            .props
254            .warnings()
255            .to_vec()
256    }
257    /// Render with checked handler props and per-request metadata. The default
258    /// document title is the name passed to `bind`; `RenderCtx::with_title`
259    /// overrides it for this request. Head/policy/preload settings keep their
260    /// existing typed wrappers. No raw script or HTML string enters this API.
261    pub fn render(&self, props: &P, ctx: &RenderCtx) -> Result<PageResponse, RenderError> {
262        crate::render::guard(&self.inner.route, || self.render_inner(props, ctx))
263    }
264
265    fn render_inner(&self, props: &P, ctx: &RenderCtx) -> Result<PageResponse, RenderError> {
266        if !self.inner.pages.is_finalized() {
267            return Err(RenderError::Unfinalized {
268                route: self.inner.route.clone(),
269            });
270        }
271        let route = &self.inner.route;
272        let (current, manifest) =
273            (self.inner.pages.get)(route).ok_or_else(|| RenderError::Binding {
274                route: route.clone(),
275                errors: vec![error(
276                    "BIND-108",
277                    route,
278                    "",
279                    "bound route no longer has an SSR module",
280                )],
281            })?;
282        let binding = {
283            let mut stored = self.inner.binding.lock().unwrap_or_else(|p| p.into_inner());
284            if !Arc::ptr_eq(stored.props.module_arc(), &current) {
285                let fresh =
286                    Binding::new(current, route).map_err(|errors| RenderError::Binding {
287                        route: route.clone(),
288                        errors,
289                    })?;
290                for warning in fresh.props.warnings() {
291                    tracing::warn!(code = warning.code, route = %route, path = %warning.path, "{warning}");
292                }
293                *stored = Arc::new(fresh);
294            }
295            Arc::clone(&stored)
296        };
297        let values = binding
298            .props
299            .encode(props)
300            .map_err(|cause| RenderError::Props { cause })?;
301        let mut slots = SlotData::new_from_defaults(&binding.props.module().slots);
302        for (name, id) in &binding.roots {
303            slots.set(
304                *id,
305                json_to_slot_value(values.get(name).unwrap_or(&serde_json::Value::Null)),
306            );
307        }
308        #[allow(deprecated)]
309        let config = PageConfig {
310            title: ctx.title().unwrap_or(&self.inner.page),
311            route_pattern: route,
312            manifest: &manifest,
313            config_json: ctx.config_json(),
314            body_class: ctx.body_class(),
315            personality_css: ctx.personality_css(),
316            body_prefix: ctx.body_prefix(),
317        };
318        crate::render::render_bound(
319            &config,
320            ctx,
321            Some(binding.props.module()),
322            Some(&slots),
323            ctx.limits(self.inner.limits),
324        )
325    }
326}
327
328fn error(code: &'static str, route: &str, path: &str, message: impl Into<String>) -> PropsError {
329    PropsError {
330        code,
331        route: route.into(),
332        path: path.into(),
333        message: message.into(),
334        typescript: None,
335        rust: None,
336    }
337}
338fn slot_type(kind: &PropType) -> SlotType {
339    match kind {
340        PropType::Optional(inner) => slot_type(inner),
341        PropType::Boolean => SlotType::Bool,
342        PropType::Number | PropType::Date => SlotType::Number,
343        PropType::Array(_) => SlotType::Array,
344        PropType::Object(_) => SlotType::Object,
345        PropType::String | PropType::Enum(_) => SlotType::Text,
346    }
347}
348fn root_slots(module: &IrModule, route: &str) -> Result<Vec<(String, u16)>, Vec<PropsError>> {
349    let Some(fields) = module.schema.objects.first() else {
350        return Err(vec![error(
351            "BIND-106",
352            route,
353            "",
354            "page has no root schema",
355        )]);
356    };
357    let mut declared = HashMap::new();
358    let mut errors = Vec::new();
359    for field in fields {
360        if let Ok(name) = module.strings.get(field.name) {
361            declared.insert(name, field);
362        }
363    }
364    let mut bindings = HashMap::new();
365    for slot in module.slots.entries() {
366        if slot.source != SlotSource::Server {
367            continue;
368        }
369        let name = module.strings.get(slot.name_str_idx).unwrap_or("");
370        let problem = match declared.get(name) {
371            None => Some("SERVER slot is not an exact declared root field"),
372            Some(field) if slot.type_hint != slot_type(&field.kind) => {
373                Some("SERVER slot type differs from its declared field")
374            }
375            Some(_) if !slot.default_bytes.is_empty() => {
376                Some("SERVER root slot must not have a client default")
377            }
378            Some(_) if bindings.contains_key(name) => {
379                Some("multiple SERVER slots bind the same declared root")
380            }
381            Some(_) => None,
382        };
383        if let Some(problem) = problem {
384            errors.push(error("BIND-106", route, name, problem));
385        } else {
386            bindings.insert(name.to_owned(), slot.slot_id);
387        }
388    }
389    let mut roots = Vec::new();
390    for field in fields {
391        let name = module.strings.get(field.name).unwrap_or("");
392        if let Some(id) = bindings.remove(name) {
393            roots.push((name.into(), id));
394        } else {
395            let mut refusal = error(
396                "BIND-106",
397                route,
398                name,
399                "declared root field has no unique SERVER slot",
400            );
401            refusal.typescript = module
402                .strings
403                .get(field.file)
404                .ok()
405                .map(|file| Box::new(PropsSource::new(file, field.line, field.column)));
406            errors.push(refusal);
407        }
408    }
409    if errors.is_empty() {
410        Ok(roots)
411    } else {
412        Err(errors)
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use forma_ir::{format::SlotEntry, parser::SlotTable};
420    const EMITTED: &[u8] = include_bytes!("../tests/fixtures/props-contract/home.ir");
421    fn slots(entries: &[SlotEntry]) -> SlotTable {
422        let mut bytes = (entries.len() as u16).to_le_bytes().to_vec();
423        for slot in entries {
424            bytes.extend(slot.slot_id.to_le_bytes());
425            bytes.extend(slot.name_str_idx.to_le_bytes());
426            bytes.push(slot.type_hint as u8);
427            bytes.push(slot.source as u8);
428            bytes.extend((slot.default_bytes.len() as u16).to_le_bytes());
429            bytes.extend(&slot.default_bytes);
430        }
431        SlotTable::parse(&bytes).unwrap()
432    }
433    #[test]
434    fn root_bindings_reject_missing_duplicate_unknown_defaulted_and_wrong_type_slots() {
435        let original = IrModule::parse(EMITTED).unwrap();
436        assert_eq!(root_slots(&original, "/").unwrap().len(), 9);
437        for change in 0..5 {
438            let mut module = IrModule::parse(EMITTED).unwrap();
439            let mut entries = module.slots.entries().to_vec();
440            match change {
441                0 => entries[0].source = SlotSource::Client,
442                1 => {
443                    let mut duplicate = entries[0].clone();
444                    duplicate.slot_id = entries.len() as u16;
445                    entries.push(duplicate);
446                }
447                2 => {
448                    entries[0].name_str_idx = (0..module.strings.len() as u32)
449                        .find(|&i| module.strings.get(i) == Ok("main"))
450                        .unwrap()
451                }
452                3 => entries[0].default_bytes = b"untrusted default".to_vec(),
453                _ => entries[0].type_hint = SlotType::Bool,
454            }
455            module.slots = slots(&entries);
456            assert!(root_slots(&module, "/")
457                .unwrap_err()
458                .iter()
459                .all(|e| e.code == "BIND-106"));
460        }
461    }
462    #[test]
463    fn a_same_named_client_slot_never_becomes_a_handler_target() {
464        let mut module = IrModule::parse(EMITTED).unwrap();
465        let mut entries = module.slots.entries().to_vec();
466        let mut local = entries[0].clone();
467        local.slot_id = entries.len() as u16;
468        local.source = SlotSource::Client;
469        local.default_bytes = b"local-only".to_vec();
470        let local_id = local.slot_id;
471        entries.push(local);
472        module.slots = slots(&entries);
473        assert!(!root_slots(&module, "/")
474            .unwrap()
475            .iter()
476            .any(|(_, id)| *id == local_id));
477    }
478}