Skip to main content

presolve_compiler/
file_route_publication.rs

1//! Compiler-owned publication for a discovered ergonomic file-route project.
2
3use 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/// Compiler-resolved request facts for one conventional file route. Server
80/// adapters consume this record instead of re-matching route patterns or
81/// deriving parameter names from source paths.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct FileRouteRequestMatchV1 {
84    pub route_path: String,
85    pub entry_component_id: String,
86    /// Exact non-empty path-segment values keyed by the compiler-issued route
87    /// parameter name. Values remain percent-encoded request segments; URL
88    /// decoding belongs to a later explicit normalization contract.
89    pub parameters: BTreeMap<String, String>,
90    pub target: FileRouteRequestTargetV1,
91}
92
93/// Builds exact compiler artifacts for every validated `app/routes` page.
94///
95/// Every page is lowered through the existing explicit application-publication
96/// product using its compiler-selected source module as the entry. The product
97/// does not interpret route source or merge generated bytes.
98pub 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/// Resolves one HTTP path using only the compiler-issued route publication
263/// manifest. The returned artifact remains an opaque compiler byte path for
264/// the host to serve.
265#[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/// Resolves a request through compiler-issued route topology and retains the
274/// selected route identity plus exact dynamic parameter values. It does not
275/// execute application code or interpret query, header, or body data.
276#[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}