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 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(), ¤t) {
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}