1use 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#[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 pub fn bind<P: PageProps>(&self, page: &str) -> Result<PageHandle<P>, Vec<PropsError>> {
62 self.bind_with(page, Limits::default())
63 }
64
65 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 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
211pub 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 pub fn route(&self) -> &str {
239 &self.inner.route
240 }
241 pub fn limits(&self) -> Limits {
243 self.inner.limits
244 }
245
246 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 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 #[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 #[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 #[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(), ¤t) {
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}