1use 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#[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
57pub 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}