Skip to main content

presolve_compiler/
route_loader.rs

1//! Compiler-owned route-loader planning over closed semantic-package facts.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::Serialize;
6
7use crate::{
8    BindingTable, ComponentNode, FileRouteGraphV1, ImportBindingTarget, SemanticPackageKind,
9    SemanticPackageRouteLoader,
10};
11
12pub const ROUTE_LOADER_PLAN_SCHEMA_VERSION: u32 = 1;
13
14#[must_use]
15pub fn route_loader_plan_json_v1(plan: &RouteLoaderPlanV1) -> String {
16    serde_json::to_string_pretty(plan).expect("route loader plan serializes") + "\n"
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct RouteLoaderPlanErrorV1 {
21    pub code: &'static str,
22    pub message: String,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26pub struct RouteLoaderPlanV1 {
27    pub schema_version: u32,
28    pub routes: Vec<RouteLoaderRouteV1>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct RouteLoaderRouteV1 {
33    pub path: String,
34    pub page_component_id: String,
35    pub loaders: Vec<RouteLoaderBindingV1>,
36}
37
38/// One fully resolved server loader. All values are exact package-contract
39/// facts; this product contains no callback or executable package source.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41pub struct RouteLoaderBindingV1 {
42    pub id: String,
43    pub component_id: String,
44    pub field: String,
45    pub package: String,
46    pub version: String,
47    pub integrity: String,
48    pub export: String,
49    pub runtime_module: String,
50    pub type_signature: String,
51    pub input: String,
52    pub cache_scope: String,
53    pub max_age_seconds: Option<u64>,
54    pub failure: String,
55}
56
57/// Resolves route page `@loader()` fields through the existing compiler
58/// binding table and published route-loader capability records.
59///
60/// # Errors
61///
62/// Returns stable errors for malformed source facts or unbound/non-loader
63/// package selections. No package implementation is inspected.
64pub fn build_route_loader_plan_v1(
65    components: &[ComponentNode],
66    graph: &FileRouteGraphV1,
67    bindings: &BindingTable,
68) -> Result<RouteLoaderPlanV1, RouteLoaderPlanErrorV1> {
69    let components = components
70        .iter()
71        .map(|component| (component.id.clone(), component))
72        .collect::<BTreeMap<_, _>>();
73    let route_components = graph
74        .routes
75        .iter()
76        .map(|route| route.component.clone())
77        .collect::<BTreeSet<_>>();
78    if let Some(component) = components.values().find(|component| {
79        !route_components.contains(&component.id)
80            && !component.route_loader_declaration_candidates.is_empty()
81    }) {
82        return Err(RouteLoaderPlanErrorV1 {
83            code: "PSROUTE1106_LOADER_NOT_ROUTE_PAGE",
84            message: format!(
85                "component `{}` declares @loader() but is not a conventional route page",
86                component.id
87            ),
88        });
89    }
90    let mut routes = Vec::new();
91    for route in &graph.routes {
92        let Some(component) = components.get(&route.component) else {
93            return Err(RouteLoaderPlanErrorV1 {
94                code: "PSROUTE1101_LOADER_ROUTE_COMPONENT_MISSING",
95                message: route.component.to_string(),
96            });
97        };
98        let mut loaders = Vec::new();
99        for candidate in &component.route_loader_declaration_candidates {
100            if !candidate.decorator_invoked
101                || candidate.decorator_argument_count != 1
102                || candidate.endpoint_designator.is_none()
103            {
104                return Err(RouteLoaderPlanErrorV1 {
105                    code: "PSROUTE1102_LOADER_DECLARATION_INVALID",
106                    message: format!(
107                        "route loader `{}` must use @loader(\"importedEndpoint\")",
108                        candidate.field
109                    ),
110                });
111            }
112            if !candidate
113                .declared_type
114                .as_ref()
115                .is_some_and(|type_| type_.text.starts_with("Resource<"))
116            {
117                return Err(RouteLoaderPlanErrorV1 {
118                    code: "PSROUTE1103_LOADER_TYPE_INVALID",
119                    message: format!(
120                        "route loader `{}` must declare Resource<Data, Error>",
121                        candidate.field
122                    ),
123                });
124            }
125            let designator = candidate
126                .endpoint_designator
127                .as_deref()
128                .expect("validated above");
129            let binding = bindings
130                .resolve_import(&component.module_path, designator)
131                .ok_or_else(|| RouteLoaderPlanErrorV1 {
132                    code: "PSROUTE1104_LOADER_ENDPOINT_UNBOUND",
133                    message: format!(
134                        "route loader `{}` cannot resolve `{designator}`",
135                        candidate.field
136                    ),
137                })?;
138            let ImportBindingTarget::SemanticPackage {
139                package,
140                version,
141                integrity,
142                export,
143                kind: SemanticPackageKind::Resource,
144                type_signature,
145                runtime_module,
146                route_loader: Some(loader),
147                ..
148            } = &binding.target
149            else {
150                return Err(RouteLoaderPlanErrorV1 {
151                    code: "PSROUTE1105_LOADER_CAPABILITY_INVALID",
152                    message: format!(
153                        "route loader `{}` must select an imported resource route_loader capability",
154                        candidate.field
155                    ),
156                });
157            };
158            loaders.push(loader_binding(
159                component,
160                &candidate.field,
161                package,
162                version,
163                integrity,
164                export,
165                runtime_module,
166                type_signature,
167                loader,
168            ));
169        }
170        routes.push(RouteLoaderRouteV1 {
171            path: route.path.clone(),
172            page_component_id: route.component.to_string(),
173            loaders,
174        });
175    }
176    Ok(RouteLoaderPlanV1 {
177        schema_version: ROUTE_LOADER_PLAN_SCHEMA_VERSION,
178        routes,
179    })
180}
181
182#[allow(clippy::too_many_arguments)]
183fn loader_binding(
184    component: &ComponentNode,
185    field: &str,
186    package: &str,
187    version: &str,
188    integrity: &str,
189    export: &str,
190    runtime_module: &str,
191    type_signature: &str,
192    loader: &SemanticPackageRouteLoader,
193) -> RouteLoaderBindingV1 {
194    let cache = &loader.cache;
195    RouteLoaderBindingV1 {
196        id: component.id.route_loader(field).to_string(),
197        component_id: component.id.to_string(),
198        field: field.into(),
199        package: package.into(),
200        version: version.into(),
201        integrity: integrity.into(),
202        export: export.into(),
203        runtime_module: runtime_module.into(),
204        type_signature: type_signature.into(),
205        input: match loader.input {
206            crate::SemanticPackageRouteLoaderInput::RouteParameters => "route_parameters".into(),
207        },
208        cache_scope: match cache.scope {
209            crate::SemanticPackageServerCacheScope::NoStore => "no_store".into(),
210            crate::SemanticPackageServerCacheScope::Private => "private".into(),
211            crate::SemanticPackageServerCacheScope::Public => "public".into(),
212        },
213        max_age_seconds: cache.max_age_seconds,
214        failure: match loader.failure {
215            crate::SemanticPackageRouteLoaderFailure::Typed => "typed".into(),
216        },
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use crate::{
223        build_application_semantic_model_for_unit_with_packages, build_binding_table_with_packages,
224        build_module_graph, build_route_loader_plan_v1, build_symbol_table,
225        build_validated_file_route_graph_v1, parse_semantic_package_contract, CompilationUnit,
226        SemanticPackageResolutionTable,
227    };
228
229    #[test]
230    fn resolves_a_route_loader_only_from_an_integrity_bound_package_capability() {
231        let unit = CompilationUnit::parse_sources([(
232            "app/routes/posts/[slug].tsx",
233            r#"
234import { loadPost } from "post-service";
235@component() class Post {
236  @loader("loadPost") post!: Resource<Post, NotFound>;
237  render() { return <article />; }
238}
239"#,
240        )]);
241        let mut packages = SemanticPackageResolutionTable::default();
242        packages
243            .insert(
244                "post-service".into(),
245                parse_semantic_package_contract(
246                    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"}}}}"#,
247                )
248                .unwrap(),
249            )
250            .unwrap();
251        let model = build_application_semantic_model_for_unit_with_packages(&unit, &packages);
252        let graph = build_validated_file_route_graph_v1(&model).unwrap();
253        let symbols = build_symbol_table(&unit);
254        let modules = build_module_graph(&unit);
255        let bindings = build_binding_table_with_packages(&unit, &symbols, &modules, &packages);
256
257        let plan = build_route_loader_plan_v1(&model.components, &graph, &bindings).unwrap();
258        assert_eq!(plan.routes.len(), 1);
259        assert_eq!(plan.routes[0].loaders.len(), 1);
260        let loader = &plan.routes[0].loaders[0];
261        assert_eq!(
262            loader.id,
263            "module:app/routes/posts/[slug].tsx/component:presolve-post/route-loader:post"
264        );
265        assert_eq!(loader.package, "post-service");
266        assert_eq!(loader.input, "route_parameters");
267        assert_eq!(loader.cache_scope, "public");
268        assert_eq!(loader.max_age_seconds, Some(60));
269    }
270}