1use std::collections::BTreeMap;
4use std::fmt;
5use std::path::PathBuf;
6
7use serde::Serialize;
8
9use crate::{
10 build_application_publication_product_from_asm_v1,
11 build_application_semantic_model_for_unit_with_packages, build_binding_table_with_packages,
12 build_file_route_application_semantic_model_for_route_with_packages,
13 build_layout_composition_plan_v1, build_module_graph, build_route_loader_plan_v1,
14 build_route_server_action_plan_v1, build_symbol_table, build_validated_file_route_graph_v1,
15 route_loader_plan_json_v1, route_server_action_plan_json_v1,
16 validate_application_publication_request_v1, ApplicationPublicationArtifactV1,
17 ApplicationPublicationErrorV1, ApplicationPublicationProfileV1,
18 ApplicationPublicationRequestErrorV1, ApplicationPublicationRequestV1,
19 ApplicationPublicationSourceV1, CompilationUnit, ConstantFoldingPass, ImmutableAsmPass,
20 SemanticPackageResolutionTable, SemanticPackageRuntimeModuleTable,
21};
22
23pub const FILE_ROUTE_PUBLICATION_MANIFEST_SCHEMA_VERSION: u32 = 1;
24pub const FILE_ROUTE_PUBLICATION_COMPILER_CONTRACT_V1: &str = "presolve-file-route-publication:1";
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct FileRoutePublicationRequestV1 {
28 pub configuration: crate::platform::WorkspaceConfiguration,
29 pub sources: Vec<ApplicationPublicationSourceV1>,
30 pub package_contracts: SemanticPackageResolutionTable,
31 pub package_runtime_modules: SemanticPackageRuntimeModuleTable,
32 pub profile: ApplicationPublicationProfileV1,
33 pub output_root: PathBuf,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct FileRoutePublicationErrorV1 {
38 pub code: &'static str,
39 pub message: String,
40}
41
42impl fmt::Display for FileRoutePublicationErrorV1 {
43 fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
44 write!(output, "{}: {}", self.code, self.message)
45 }
46}
47
48impl std::error::Error for FileRoutePublicationErrorV1 {}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
51pub struct FileRoutePublicationManifestV1 {
52 pub schema_version: u32,
53 pub compiler_contract: String,
54 pub profile: String,
55 pub routes: Vec<FileRoutePublicationRouteV1>,
56 pub artifacts: Vec<ApplicationPublicationArtifactV1>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
60pub struct FileRoutePublicationRouteV1 {
61 pub path: String,
62 pub entry_component_id: String,
63 pub artifact_root: String,
64 pub layout_component_ids: Vec<String>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct FileRoutePublicationProductV1 {
69 pub manifest: FileRoutePublicationManifestV1,
70 pub artifacts: BTreeMap<PathBuf, Vec<u8>>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum FileRouteRequestTargetV1 {
75 Redirect { location: String },
76 Artifact { path: PathBuf },
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct FileRouteRequestMatchV1 {
84 pub route_path: String,
85 pub entry_component_id: String,
86 pub parameters: BTreeMap<String, String>,
90 pub target: FileRouteRequestTargetV1,
91}
92
93pub fn build_file_route_publication_v1(
99 request: FileRoutePublicationRequestV1,
100) -> Result<FileRoutePublicationProductV1, FileRoutePublicationErrorV1> {
101 if request.sources.is_empty() {
102 return Err(FileRoutePublicationErrorV1 {
103 code: "PSROUTE2001_EMPTY_FILE_ROUTE_SOURCE_SET",
104 message: "file-route publication requires discovered application sources".into(),
105 });
106 }
107 let unit = CompilationUnit::parse_sources(
108 request
109 .sources
110 .iter()
111 .map(|source| (&source.logical_path, source.source.as_str())),
112 );
113 let model =
114 build_application_semantic_model_for_unit_with_packages(&unit, &request.package_contracts);
115 let graph = build_validated_file_route_graph_v1(&model).map_err(route_error)?;
116 build_layout_composition_plan_v1(&model, &graph).map_err(|error| {
117 FileRoutePublicationErrorV1 {
118 code: error.code,
119 message: error.message,
120 }
121 })?;
122 let symbols = build_symbol_table(&unit);
123 let modules = build_module_graph(&unit);
124 let bindings =
125 build_binding_table_with_packages(&unit, &symbols, &modules, &request.package_contracts);
126 let route_loader_plan = build_route_loader_plan_v1(&model.components, &graph, &bindings)
127 .map_err(|error| FileRoutePublicationErrorV1 {
128 code: error.code,
129 message: error.message,
130 })?;
131 let route_server_action_plan =
132 build_route_server_action_plan_v1(&model.components, &graph, &bindings).map_err(
133 |error| FileRoutePublicationErrorV1 {
134 code: error.code,
135 message: error.message,
136 },
137 )?;
138 if graph.routes.is_empty() {
139 return Err(FileRoutePublicationErrorV1 {
140 code: "PSROUTE2002_FILE_ROUTE_SET_EMPTY",
141 message: "no rendered components were discovered below app/routes".into(),
142 });
143 }
144
145 let component_modules = model
146 .components
147 .iter()
148 .map(|component| (component.id.clone(), component.module_path.clone()))
149 .collect::<BTreeMap<_, _>>();
150 let mut artifacts = BTreeMap::new();
151 let mut routes = Vec::new();
152 for route in &graph.routes {
153 let entry_path =
154 component_modules
155 .get(&route.component)
156 .ok_or_else(|| FileRoutePublicationErrorV1 {
157 code: "PSROUTE2003_ROUTE_COMPONENT_MODULE_MISSING",
158 message: route.component.to_string(),
159 })?;
160 let mut validated =
161 validate_application_publication_request_v1(ApplicationPublicationRequestV1 {
162 configuration: request.configuration.clone(),
163 sources: request.sources.clone(),
164 entry_path: entry_path.clone(),
165 package_contracts: request.package_contracts.clone(),
166 package_runtime_modules: request.package_runtime_modules.clone(),
167 profile: request.profile,
168 output_root: request.output_root.clone(),
169 })
170 .map_err(application_request_error)?;
171 let composed = ConstantFoldingPass.transform(
172 &build_file_route_application_semantic_model_for_route_with_packages(
173 &unit,
174 &request.package_contracts,
175 &route.component,
176 )
177 .map_err(|error| FileRoutePublicationErrorV1 {
178 code: error.code,
179 message: error.message,
180 })?,
181 );
182 validated.render_root_component = route
183 .layouts
184 .first()
185 .cloned()
186 .unwrap_or_else(|| route.component.clone());
187 let product = build_application_publication_product_from_asm_v1(validated, composed)
188 .map_err(application_product_error)?;
189 let artifact_root = file_route_artifact_root_v1(&route.path);
190 for (path, bytes) in product.artifacts {
191 let path = PathBuf::from(&artifact_root).join(path);
192 if artifacts.insert(path.clone(), bytes).is_some() {
193 return Err(FileRoutePublicationErrorV1 {
194 code: "PSROUTE2004_FILE_ROUTE_ARTIFACT_COLLISION",
195 message: path.display().to_string(),
196 });
197 }
198 }
199 routes.push(FileRoutePublicationRouteV1 {
200 path: route.path.clone(),
201 entry_component_id: route.component.to_string(),
202 artifact_root,
203 layout_component_ids: route.layouts.iter().map(ToString::to_string).collect(),
204 });
205 }
206 routes.sort_by(|left, right| left.path.cmp(&right.path));
207 artifacts.insert(
208 PathBuf::from("route-loaders.plan.json"),
209 route_loader_plan_json_v1(&route_loader_plan).into_bytes(),
210 );
211 artifacts.insert(
212 PathBuf::from("route-server-actions.plan.json"),
213 route_server_action_plan_json_v1(&route_server_action_plan).into_bytes(),
214 );
215 let manifest = FileRoutePublicationManifestV1 {
216 schema_version: FILE_ROUTE_PUBLICATION_MANIFEST_SCHEMA_VERSION,
217 compiler_contract: FILE_ROUTE_PUBLICATION_COMPILER_CONTRACT_V1.into(),
218 profile: request.profile.as_str().into(),
219 routes,
220 artifacts: artifacts
221 .iter()
222 .map(|(path, bytes)| ApplicationPublicationArtifactV1 {
223 path: path.to_string_lossy().replace('\\', "/"),
224 digest: crate::platform::Digest::sha256(bytes).to_string(),
225 })
226 .collect(),
227 };
228 artifacts.insert(
229 PathBuf::from("file-routes.manifest.json"),
230 file_route_publication_manifest_json_v1(&manifest).into_bytes(),
231 );
232 Ok(FileRoutePublicationProductV1 {
233 manifest,
234 artifacts,
235 })
236}
237
238#[must_use]
239pub fn file_route_publication_manifest_json_v1(value: &FileRoutePublicationManifestV1) -> String {
240 serde_json::to_string_pretty(value).expect("file-route publication manifest serializes") + "\n"
241}
242
243#[must_use]
244pub fn file_route_artifact_root_v1(path: &str) -> String {
245 if path == "/" {
246 return "routes/root".into();
247 }
248 let segments = path
249 .trim_matches('/')
250 .split('/')
251 .map(|segment| {
252 if let Some(parameter) = segment.strip_prefix(':') {
253 format!("parameter-{parameter}")
254 } else {
255 format!("segment-{segment}")
256 }
257 })
258 .collect::<Vec<_>>();
259 format!("routes/{}", segments.join("/"))
260}
261
262#[must_use]
266pub fn resolve_file_route_request_v1(
267 manifest: &FileRoutePublicationManifestV1,
268 request_path: &str,
269) -> Option<FileRouteRequestTargetV1> {
270 resolve_file_route_request_match_v1(manifest, request_path).map(|match_| match_.target)
271}
272
273#[must_use]
277pub fn resolve_file_route_request_match_v1(
278 manifest: &FileRoutePublicationManifestV1,
279 request_path: &str,
280) -> Option<FileRouteRequestMatchV1> {
281 if !request_path.starts_with('/') || request_path.contains('?') || request_path.contains('#') {
282 return None;
283 }
284 let trailing_slash = request_path.ends_with('/');
285 let segments = request_path
286 .trim_matches('/')
287 .split('/')
288 .filter(|segment| !segment.is_empty())
289 .collect::<Vec<_>>();
290 let route = manifest
291 .routes
292 .iter()
293 .filter_map(|route| {
294 let route_segments = route
295 .path
296 .trim_matches('/')
297 .split('/')
298 .filter(|segment| !segment.is_empty())
299 .collect::<Vec<_>>();
300 (segments.len() >= route_segments.len()
301 && route_segments
302 .iter()
303 .zip(&segments)
304 .all(|(route, request)| {
305 route.strip_prefix(':').is_some_and(|_| !request.is_empty())
306 || route == request
307 }))
308 .then_some((route, route_segments))
309 })
310 .max_by(|(_, left_segments), (_, right_segments)| {
311 route_match_score(left_segments).cmp(&route_match_score(right_segments))
312 })?;
313 let (route, route_segments) = route;
314 let parameters = route_segments
315 .iter()
316 .zip(&segments)
317 .filter_map(|(route_segment, request_segment)| {
318 route_segment
319 .strip_prefix(':')
320 .map(|name| (name.to_string(), (*request_segment).to_string()))
321 })
322 .collect::<BTreeMap<_, _>>();
323 let match_record = |target| FileRouteRequestMatchV1 {
324 route_path: route.path.clone(),
325 entry_component_id: route.entry_component_id.clone(),
326 parameters: parameters.clone(),
327 target,
328 };
329 if segments.len() == route_segments.len() {
330 if route.path != "/" && !trailing_slash {
331 return Some(match_record(FileRouteRequestTargetV1::Redirect {
332 location: format!("{request_path}/"),
333 }));
334 }
335 return Some(match_record(FileRouteRequestTargetV1::Artifact {
336 path: PathBuf::from(&route.artifact_root).join("index.html"),
337 }));
338 }
339 let suffix = &segments[route_segments.len()..];
340 suffix
341 .iter()
342 .all(|segment| is_safe_request_asset_segment(segment))
343 .then(|| {
344 match_record(FileRouteRequestTargetV1::Artifact {
345 path: PathBuf::from(&route.artifact_root).join(suffix.iter().collect::<PathBuf>()),
346 })
347 })
348}
349
350fn route_match_score(segments: &[&str]) -> (usize, usize) {
351 (
352 segments
353 .iter()
354 .filter(|segment| !segment.starts_with(':'))
355 .count(),
356 segments.len(),
357 )
358}
359
360fn is_safe_request_asset_segment(segment: &str) -> bool {
361 !segment.is_empty() && segment != "." && segment != ".." && !segment.contains('\\')
362}
363
364fn route_error(error: crate::RouteGraphError) -> FileRoutePublicationErrorV1 {
365 FileRoutePublicationErrorV1 {
366 code: error.code,
367 message: error.message,
368 }
369}
370
371fn application_request_error(
372 error: ApplicationPublicationRequestErrorV1,
373) -> FileRoutePublicationErrorV1 {
374 FileRoutePublicationErrorV1 {
375 code: error.code,
376 message: error.message,
377 }
378}
379
380fn application_product_error(error: ApplicationPublicationErrorV1) -> FileRoutePublicationErrorV1 {
381 FileRoutePublicationErrorV1 {
382 code: error.code,
383 message: error.message,
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn publishes_each_file_route_under_a_distinct_compiler_owned_root() {
393 let product = build_file_route_publication_v1(FileRoutePublicationRequestV1 {
394 configuration: crate::platform::WorkspaceConfiguration::default(),
395 sources: vec![
396 ApplicationPublicationSourceV1 {
397 logical_path: "app/routes/index.tsx".into(),
398 source: r#"@component() class Home extends Component { render() { return <main>Home</main>; } }"#.into(),
399 },
400 ApplicationPublicationSourceV1 {
401 logical_path: "app/routes/about.tsx".into(),
402 source: r#"@component() class About extends Component { render() { return <main>About</main>; } }"#.into(),
403 },
404 ],
405 package_contracts: SemanticPackageResolutionTable::default(),
406 package_runtime_modules: SemanticPackageRuntimeModuleTable::default(),
407 profile: ApplicationPublicationProfileV1::Development,
408 output_root: "dist".into(),
409 })
410 .unwrap();
411
412 assert_eq!(product.manifest.routes.len(), 2);
413 let home =
414 String::from_utf8(product.artifacts[&PathBuf::from("routes/root/index.html")].clone())
415 .unwrap();
416 let about = String::from_utf8(
417 product.artifacts[&PathBuf::from("routes/segment-about/index.html")].clone(),
418 )
419 .unwrap();
420 assert!(home.contains(">Home</main>"));
421 assert!(!home.contains(">About</main>"));
422 assert!(about.contains(">About</main>"));
423 assert!(product
424 .artifacts
425 .contains_key(&PathBuf::from("file-routes.manifest.json")));
426 }
427
428 #[test]
429 fn publishes_a_route_through_its_compiler_composed_layout_root() {
430 let product = build_file_route_publication_v1(FileRoutePublicationRequestV1 {
431 configuration: crate::platform::WorkspaceConfiguration::default(),
432 sources: vec![
433 ApplicationPublicationSourceV1 {
434 logical_path: "app/layout.tsx".into(),
435 source: r#"
436@component() class AppLayout extends Component {
437 @slot() children!: SlotContent;
438 render() { return <main><slot /></main>; }
439}
440"#
441 .into(),
442 },
443 ApplicationPublicationSourceV1 {
444 logical_path: "app/routes/index.tsx".into(),
445 source: r#"@component() class Home extends Component { render() { return <article>Home</article>; } }"#.into(),
446 },
447 ],
448 package_contracts: SemanticPackageResolutionTable::default(),
449 package_runtime_modules: SemanticPackageRuntimeModuleTable::default(),
450 profile: ApplicationPublicationProfileV1::Development,
451 output_root: "dist".into(),
452 })
453 .expect("valid route layout publication");
454
455 let html =
456 String::from_utf8(product.artifacts[&PathBuf::from("routes/root/index.html")].clone())
457 .unwrap();
458 assert!(html.contains("<main"));
459 assert!(html.contains("<article"));
460 assert!(html.contains("Home"));
461 }
462
463 #[test]
464 fn publishes_an_exact_route_loader_handoff_plan_without_server_execution() {
465 let contract = crate::parse_semantic_package_contract(
466 r#"{"schema_version":1,"package":"post-service","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"loadPost":{"kind":"resource","type_signature":"RouteParameters -> Resource<Post, NotFound>","runtime_module":"dist/load-post.js","resume_policy":"reload","resource_endpoint":{"execution_boundary":"server","cancellation":"abort","resume":"reload"},"route_loader":{"input":"route_parameters","cache":{"scope":"public","max_age_seconds":60},"failure":"typed"}}}}"#,
467 )
468 .unwrap();
469 let mut contracts = SemanticPackageResolutionTable::default();
470 contracts.insert("post-service".into(), contract).unwrap();
471 let product = build_file_route_publication_v1(FileRoutePublicationRequestV1 {
472 configuration: crate::platform::WorkspaceConfiguration::default(),
473 sources: vec![ApplicationPublicationSourceV1 {
474 logical_path: "app/routes/posts/[slug].tsx".into(),
475 source: r#"
476import { loadPost } from "post-service";
477@component() class Post {
478 @loader("loadPost") post!: Resource<Post, NotFound>;
479 render() { return <article />; }
480}
481"#
482 .into(),
483 }],
484 package_contracts: contracts,
485 package_runtime_modules: SemanticPackageRuntimeModuleTable::default(),
486 profile: ApplicationPublicationProfileV1::Development,
487 output_root: "dist".into(),
488 })
489 .expect("route loader handoff publication");
490
491 let plan =
492 String::from_utf8(product.artifacts[&PathBuf::from("route-loaders.plan.json")].clone())
493 .unwrap();
494 assert!(plan.contains("post-service"));
495 assert!(plan.contains("route_parameters"));
496 assert!(plan.contains("max_age_seconds"));
497 assert!(!plan.contains("function loadPost"));
498 }
499
500 #[test]
501 fn publishes_an_exact_route_server_action_handoff_without_server_execution() {
502 let contract = crate::parse_semantic_package_contract(
503 r#"{"schema_version":1,"package":"post-service","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"savePost":{"kind":"server_action","type_signature":"FormData -> ServerActionResult","runtime_module":"dist/save-post.js","resume_policy":"cold_fallback","server_action":{"input":"form_data","response":"redirect","failure":"typed"}}}}"#,
504 )
505 .unwrap();
506 let mut contracts = SemanticPackageResolutionTable::default();
507 contracts.insert("post-service".into(), contract).unwrap();
508 let product = build_file_route_publication_v1(FileRoutePublicationRequestV1 {
509 configuration: crate::platform::WorkspaceConfiguration::default(),
510 sources: vec![ApplicationPublicationSourceV1 {
511 logical_path: "app/routes/posts/[slug].tsx".into(),
512 source: r#"
513import { savePost } from "post-service";
514@component() class Post {
515 @serverAction("savePost") save(): void {}
516 render() { return <form />; }
517}
518"#
519 .into(),
520 }],
521 package_contracts: contracts,
522 package_runtime_modules: SemanticPackageRuntimeModuleTable::default(),
523 profile: ApplicationPublicationProfileV1::Development,
524 output_root: "dist".into(),
525 })
526 .expect("route server-action handoff publication");
527
528 let plan = String::from_utf8(
529 product.artifacts[&PathBuf::from("route-server-actions.plan.json")].clone(),
530 )
531 .unwrap();
532 assert!(plan.contains("post-service"));
533 assert!(plan.contains("form_data"));
534 assert!(plan.contains("redirect"));
535 assert!(plan.contains("cold_fallback"));
536 assert!(!plan.contains("function savePost"));
537 }
538
539 #[test]
540 fn resolves_page_and_asset_paths_from_the_compiler_manifest() {
541 let manifest = FileRoutePublicationManifestV1 {
542 schema_version: 1,
543 compiler_contract: FILE_ROUTE_PUBLICATION_COMPILER_CONTRACT_V1.into(),
544 profile: "development".into(),
545 routes: vec![
546 FileRoutePublicationRouteV1 {
547 path: "/posts/:slug".into(),
548 entry_component_id: "component:post".into(),
549 artifact_root: "routes/segment-posts/parameter-slug".into(),
550 layout_component_ids: Vec::new(),
551 },
552 FileRoutePublicationRouteV1 {
553 path: "/posts/new".into(),
554 entry_component_id: "component:new".into(),
555 artifact_root: "routes/segment-posts/segment-new".into(),
556 layout_component_ids: Vec::new(),
557 },
558 ],
559 artifacts: Vec::new(),
560 };
561 assert_eq!(
562 resolve_file_route_request_v1(&manifest, "/posts/new"),
563 Some(FileRouteRequestTargetV1::Redirect {
564 location: "/posts/new/".into()
565 })
566 );
567 assert_eq!(
568 resolve_file_route_request_v1(&manifest, "/posts/hello/runtime.js"),
569 Some(FileRouteRequestTargetV1::Artifact {
570 path: "routes/segment-posts/parameter-slug/runtime.js".into()
571 })
572 );
573 }
574
575 #[test]
576 fn retains_dynamic_parameter_facts_from_the_selected_compiler_route() {
577 let manifest = FileRoutePublicationManifestV1 {
578 schema_version: 1,
579 compiler_contract: FILE_ROUTE_PUBLICATION_COMPILER_CONTRACT_V1.into(),
580 profile: "development".into(),
581 routes: vec![FileRoutePublicationRouteV1 {
582 path: "/posts/:slug/comments/:commentId".into(),
583 entry_component_id: "component:comment".into(),
584 artifact_root: "routes/comment".into(),
585 layout_component_ids: Vec::new(),
586 }],
587 artifacts: Vec::new(),
588 };
589
590 let resolved =
591 resolve_file_route_request_match_v1(&manifest, "/posts/hello-world/comments/42/")
592 .expect("selected route");
593 assert_eq!(resolved.route_path, "/posts/:slug/comments/:commentId");
594 assert_eq!(resolved.entry_component_id, "component:comment");
595 assert_eq!(
596 resolved.parameters,
597 BTreeMap::from([
598 ("slug".to_string(), "hello-world".to_string()),
599 ("commentId".to_string(), "42".to_string()),
600 ])
601 );
602 assert_eq!(
603 resolved.target,
604 FileRouteRequestTargetV1::Artifact {
605 path: "routes/comment/index.html".into(),
606 }
607 );
608 }
609}