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    /// Render the unreleased navigation representation from the same verified
266    /// props and FMIR module. Requires `RenderCtx::with_navigation`.
267    #[cfg(feature = "experimental-router")]
268    pub fn render_navigation(
269        &self,
270        props: &P,
271        ctx: &RenderCtx,
272    ) -> Result<crate::NavigationResponse, RenderError> {
273        crate::render::guard(&self.inner.route, || {
274            self.with_render_input(props, ctx, |config, module, slots, limits| {
275                crate::render::render_bound_navigation(config, ctx, module, slots, limits)
276            })
277        })
278    }
279
280    /// Render an opaque owner using this finalized typed binding. The compiler
281    /// supplies its identity/outlet; the server hashes all checked declared props.
282    /// `reuse_key` identifies a server-owned lifetime and is never an auth token.
283    #[cfg(feature = "navigation")]
284    pub fn render_owner(
285        &self,
286        props: &P,
287        ctx: &RenderCtx,
288        reuse_key: &str,
289    ) -> Result<crate::NavigationOwner, RenderError> {
290        crate::render::guard(&self.inner.route, || {
291            self.with_render_values(props, ctx, |config, module, slots, limits, values| {
292                crate::render::render_bound_owner(
293                    config, ctx, module, slots, limits, values, reuse_key,
294                )
295            })
296        })
297    }
298
299    /// Bind an enhanced action outcome to this finalized page's app/build and
300    /// document policy. `endpoint` must be an exact canonical pathname chosen
301    /// by the handler, not a URL copied from untrusted request input.
302    #[cfg(feature = "navigation")]
303    pub fn action_context(
304        &self,
305        ctx: &RenderCtx,
306        endpoint: &str,
307    ) -> Result<crate::ActionContext, RenderError> {
308        crate::render::guard(&self.inner.route, || {
309            self.with_bound_config(ctx, |config, _, _| {
310                crate::ActionContext::from_bound(config, ctx, endpoint)
311            })
312        })
313    }
314
315    fn render_inner(&self, props: &P, ctx: &RenderCtx) -> Result<PageResponse, RenderError> {
316        self.with_render_input(props, ctx, |config, module, slots, limits| {
317            crate::render::render_bound(config, ctx, Some(module), Some(slots), limits)
318        })
319    }
320
321    fn with_render_input<T>(
322        &self,
323        props: &P,
324        ctx: &RenderCtx,
325        finish: impl FnOnce(
326            &PageConfig,
327            &forma_ir::IrModule,
328            &SlotData,
329            Limits,
330        ) -> Result<T, RenderError>,
331    ) -> Result<T, RenderError> {
332        self.with_render_values(props, ctx, |config, module, slots, limits, _| {
333            finish(config, module, slots, limits)
334        })
335    }
336
337    fn with_render_values<T>(
338        &self,
339        props: &P,
340        ctx: &RenderCtx,
341        finish: impl FnOnce(
342            &PageConfig,
343            &forma_ir::IrModule,
344            &SlotData,
345            Limits,
346            &serde_json::Value,
347        ) -> Result<T, RenderError>,
348    ) -> Result<T, RenderError> {
349        self.with_bound_config(ctx, |config, binding, limits| {
350            let values = binding
351                .props
352                .encode(props)
353                .map_err(|cause| RenderError::Props { cause })?;
354            let mut slots = SlotData::new_from_defaults(&binding.props.module().slots);
355            for (name, id) in &binding.roots {
356                slots.set(
357                    *id,
358                    json_to_slot_value(values.get(name).unwrap_or(&serde_json::Value::Null)),
359                );
360            }
361            finish(config, binding.props.module(), &slots, limits, &values)
362        })
363    }
364
365    fn with_bound_config<T>(
366        &self,
367        ctx: &RenderCtx,
368        finish: impl FnOnce(&PageConfig, &Binding<P>, Limits) -> Result<T, RenderError>,
369    ) -> Result<T, RenderError> {
370        if !self.inner.pages.is_finalized() {
371            return Err(RenderError::Unfinalized {
372                route: self.inner.route.clone(),
373            });
374        }
375        let route = &self.inner.route;
376        let (current, manifest) =
377            (self.inner.pages.get)(route).ok_or_else(|| RenderError::Binding {
378                route: route.clone(),
379                errors: vec![error(
380                    "BIND-108",
381                    route,
382                    "",
383                    "bound route no longer has an SSR module",
384                )],
385            })?;
386        let binding = {
387            let mut stored = self.inner.binding.lock().unwrap_or_else(|p| p.into_inner());
388            if !Arc::ptr_eq(stored.props.module_arc(), &current) {
389                let fresh =
390                    Binding::new(current, route).map_err(|errors| RenderError::Binding {
391                        route: route.clone(),
392                        errors,
393                    })?;
394                for warning in fresh.props.warnings() {
395                    tracing::warn!(code = warning.code, route = %route, path = %warning.path, "{warning}");
396                }
397                *stored = Arc::new(fresh);
398            }
399            Arc::clone(&stored)
400        };
401        #[allow(deprecated)]
402        let config = PageConfig {
403            title: ctx.title().unwrap_or(&self.inner.page),
404            route_pattern: route,
405            manifest: &manifest,
406            config_json: ctx.config_json(),
407            body_class: ctx.body_class(),
408            personality_css: ctx.personality_css(),
409            body_prefix: ctx.body_prefix(),
410        };
411        finish(&config, &binding, ctx.limits(self.inner.limits))
412    }
413}
414
415fn error(code: &'static str, route: &str, path: &str, message: impl Into<String>) -> PropsError {
416    PropsError {
417        code,
418        route: route.into(),
419        path: path.into(),
420        message: message.into(),
421        typescript: None,
422        rust: None,
423    }
424}
425fn slot_type(kind: &PropType) -> SlotType {
426    match kind {
427        PropType::Optional(inner) => slot_type(inner),
428        PropType::Boolean => SlotType::Bool,
429        PropType::Number | PropType::Date => SlotType::Number,
430        PropType::Array(_) => SlotType::Array,
431        PropType::Object(_) => SlotType::Object,
432        PropType::String | PropType::Enum(_) => SlotType::Text,
433    }
434}
435fn root_slots(module: &IrModule, route: &str) -> Result<Vec<(String, u16)>, Vec<PropsError>> {
436    let Some(fields) = module.schema.objects.first() else {
437        return Err(vec![error(
438            "BIND-106",
439            route,
440            "",
441            "page has no root schema",
442        )]);
443    };
444    let mut declared = HashMap::new();
445    let mut errors = Vec::new();
446    for field in fields {
447        if let Ok(name) = module.strings.get(field.name) {
448            declared.insert(name, field);
449        }
450    }
451    let mut bindings = HashMap::new();
452    for slot in module.slots.entries() {
453        if slot.source != SlotSource::Server {
454            continue;
455        }
456        let name = module.strings.get(slot.name_str_idx).unwrap_or("");
457        let problem = match declared.get(name) {
458            None => Some("SERVER slot is not an exact declared root field"),
459            Some(field) if slot.type_hint != slot_type(&field.kind) => {
460                Some("SERVER slot type differs from its declared field")
461            }
462            Some(_) if !slot.default_bytes.is_empty() => {
463                Some("SERVER root slot must not have a client default")
464            }
465            Some(_) if bindings.contains_key(name) => {
466                Some("multiple SERVER slots bind the same declared root")
467            }
468            Some(_) => None,
469        };
470        if let Some(problem) = problem {
471            errors.push(error("BIND-106", route, name, problem));
472        } else {
473            bindings.insert(name.to_owned(), slot.slot_id);
474        }
475    }
476    let mut roots = Vec::new();
477    for field in fields {
478        let name = module.strings.get(field.name).unwrap_or("");
479        if let Some(id) = bindings.remove(name) {
480            roots.push((name.into(), id));
481        } else {
482            let mut refusal = error(
483                "BIND-106",
484                route,
485                name,
486                "declared root field has no unique SERVER slot",
487            );
488            refusal.typescript = module
489                .strings
490                .get(field.file)
491                .ok()
492                .map(|file| Box::new(PropsSource::new(file, field.line, field.column)));
493            errors.push(refusal);
494        }
495    }
496    if errors.is_empty() {
497        Ok(roots)
498    } else {
499        Err(errors)
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use forma_ir::{format::SlotEntry, parser::SlotTable};
507    const EMITTED: &[u8] = include_bytes!("../tests/fixtures/props-contract/home.ir");
508    fn slots(entries: &[SlotEntry]) -> SlotTable {
509        let mut bytes = (entries.len() as u16).to_le_bytes().to_vec();
510        for slot in entries {
511            bytes.extend(slot.slot_id.to_le_bytes());
512            bytes.extend(slot.name_str_idx.to_le_bytes());
513            bytes.push(slot.type_hint as u8);
514            bytes.push(slot.source as u8);
515            bytes.extend((slot.default_bytes.len() as u16).to_le_bytes());
516            bytes.extend(&slot.default_bytes);
517        }
518        SlotTable::parse(&bytes).unwrap()
519    }
520    #[test]
521    fn root_bindings_reject_missing_duplicate_unknown_defaulted_and_wrong_type_slots() {
522        let original = IrModule::parse(EMITTED).unwrap();
523        assert_eq!(root_slots(&original, "/").unwrap().len(), 9);
524        for change in 0..5 {
525            let mut module = IrModule::parse(EMITTED).unwrap();
526            let mut entries = module.slots.entries().to_vec();
527            match change {
528                0 => entries[0].source = SlotSource::Client,
529                1 => {
530                    let mut duplicate = entries[0].clone();
531                    duplicate.slot_id = entries.len() as u16;
532                    entries.push(duplicate);
533                }
534                2 => {
535                    entries[0].name_str_idx = (0..module.strings.len() as u32)
536                        .find(|&i| module.strings.get(i) == Ok("main"))
537                        .unwrap()
538                }
539                3 => entries[0].default_bytes = b"untrusted default".to_vec(),
540                _ => entries[0].type_hint = SlotType::Bool,
541            }
542            module.slots = slots(&entries);
543            assert!(root_slots(&module, "/")
544                .unwrap_err()
545                .iter()
546                .all(|e| e.code == "BIND-106"));
547        }
548    }
549    #[test]
550    fn a_same_named_client_slot_never_becomes_a_handler_target() {
551        let mut module = IrModule::parse(EMITTED).unwrap();
552        let mut entries = module.slots.entries().to_vec();
553        let mut local = entries[0].clone();
554        local.slot_id = entries.len() as u16;
555        local.source = SlotSource::Client;
556        local.default_bytes = b"local-only".to_vec();
557        let local_id = local.slot_id;
558        entries.push(local);
559        module.slots = slots(&entries);
560        assert!(!root_slots(&module, "/")
561            .unwrap()
562            .iter()
563            .any(|(_, id)| *id == local_id));
564    }
565}