//! Compiler-owned publication for a discovered ergonomic file-route project.
use std::collections::BTreeMap;
use std::fmt;
use std::path::PathBuf;
use serde::Serialize;
use crate::{
build_application_publication_product_from_asm_v1,
build_application_semantic_model_for_unit_with_packages, build_binding_table_with_packages,
build_file_route_application_semantic_model_for_route_with_packages,
build_layout_composition_plan_v1, build_module_graph, build_route_loader_plan_v1,
build_route_server_action_plan_v1, build_symbol_table, build_validated_file_route_graph_v1,
route_loader_plan_json_v1, route_server_action_plan_json_v1,
validate_application_publication_request_v1, ApplicationPublicationArtifactV1,
ApplicationPublicationErrorV1, ApplicationPublicationProfileV1,
ApplicationPublicationRequestErrorV1, ApplicationPublicationRequestV1,
ApplicationPublicationSourceV1, CompilationUnit, ConstantFoldingPass, ImmutableAsmPass,
SemanticPackageResolutionTable, SemanticPackageRuntimeModuleTable,
};
pub const FILE_ROUTE_PUBLICATION_MANIFEST_SCHEMA_VERSION: u32 = 1;
pub const FILE_ROUTE_PUBLICATION_COMPILER_CONTRACT_V1: &str = "presolve-file-route-publication:1";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileRoutePublicationRequestV1 {
pub configuration: crate::platform::WorkspaceConfiguration,
pub sources: Vec<ApplicationPublicationSourceV1>,
pub package_contracts: SemanticPackageResolutionTable,
pub package_runtime_modules: SemanticPackageRuntimeModuleTable,
pub profile: ApplicationPublicationProfileV1,
pub output_root: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileRoutePublicationErrorV1 {
pub code: &'static str,
pub message: String,
}
impl fmt::Display for FileRoutePublicationErrorV1 {
fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(output, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for FileRoutePublicationErrorV1 {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FileRoutePublicationManifestV1 {
pub schema_version: u32,
pub compiler_contract: String,
pub profile: String,
pub routes: Vec<FileRoutePublicationRouteV1>,
pub artifacts: Vec<ApplicationPublicationArtifactV1>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FileRoutePublicationRouteV1 {
pub path: String,
pub entry_component_id: String,
pub artifact_root: String,
pub layout_component_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileRoutePublicationProductV1 {
pub manifest: FileRoutePublicationManifestV1,
pub artifacts: BTreeMap<PathBuf, Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileRouteRequestTargetV1 {
Redirect { location: String },
Artifact { path: PathBuf },
}
/// Compiler-resolved request facts for one conventional file route. Server
/// adapters consume this record instead of re-matching route patterns or
/// deriving parameter names from source paths.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileRouteRequestMatchV1 {
pub route_path: String,
pub entry_component_id: String,
/// Exact non-empty path-segment values keyed by the compiler-issued route
/// parameter name. Values remain percent-encoded request segments; URL
/// decoding belongs to a later explicit normalization contract.
pub parameters: BTreeMap<String, String>,
pub target: FileRouteRequestTargetV1,
}
/// Builds exact compiler artifacts for every validated `app/routes` page.
///
/// Every page is lowered through the existing explicit application-publication
/// product using its compiler-selected source module as the entry. The product
/// does not interpret route source or merge generated bytes.
pub fn build_file_route_publication_v1(
request: FileRoutePublicationRequestV1,
) -> Result<FileRoutePublicationProductV1, FileRoutePublicationErrorV1> {
if request.sources.is_empty() {
return Err(FileRoutePublicationErrorV1 {
code: "PSROUTE2001_EMPTY_FILE_ROUTE_SOURCE_SET",
message: "file-route publication requires discovered application sources".into(),
});
}
let unit = CompilationUnit::parse_sources(
request
.sources
.iter()
.map(|source| (&source.logical_path, source.source.as_str())),
);
let model =
build_application_semantic_model_for_unit_with_packages(&unit, &request.package_contracts);
let graph = build_validated_file_route_graph_v1(&model).map_err(route_error)?;
build_layout_composition_plan_v1(&model, &graph).map_err(|error| {
FileRoutePublicationErrorV1 {
code: error.code,
message: error.message,
}
})?;
let symbols = build_symbol_table(&unit);
let modules = build_module_graph(&unit);
let bindings =
build_binding_table_with_packages(&unit, &symbols, &modules, &request.package_contracts);
let route_loader_plan = build_route_loader_plan_v1(&model.components, &graph, &bindings)
.map_err(|error| FileRoutePublicationErrorV1 {
code: error.code,
message: error.message,
})?;
let route_server_action_plan =
build_route_server_action_plan_v1(&model.components, &graph, &bindings).map_err(
|error| FileRoutePublicationErrorV1 {
code: error.code,
message: error.message,
},
)?;
if graph.routes.is_empty() {
return Err(FileRoutePublicationErrorV1 {
code: "PSROUTE2002_FILE_ROUTE_SET_EMPTY",
message: "no rendered components were discovered below app/routes".into(),
});
}
let component_modules = model
.components
.iter()
.map(|component| (component.id.clone(), component.module_path.clone()))
.collect::<BTreeMap<_, _>>();
let mut artifacts = BTreeMap::new();
let mut routes = Vec::new();
for route in &graph.routes {
let entry_path =
component_modules
.get(&route.component)
.ok_or_else(|| FileRoutePublicationErrorV1 {
code: "PSROUTE2003_ROUTE_COMPONENT_MODULE_MISSING",
message: route.component.to_string(),
})?;
let mut validated =
validate_application_publication_request_v1(ApplicationPublicationRequestV1 {
configuration: request.configuration.clone(),
sources: request.sources.clone(),
entry_path: entry_path.clone(),
package_contracts: request.package_contracts.clone(),
package_runtime_modules: request.package_runtime_modules.clone(),
profile: request.profile,
output_root: request.output_root.clone(),
})
.map_err(application_request_error)?;
let composed = ConstantFoldingPass.transform(
&build_file_route_application_semantic_model_for_route_with_packages(
&unit,
&request.package_contracts,
&route.component,
)
.map_err(|error| FileRoutePublicationErrorV1 {
code: error.code,
message: error.message,
})?,
);
validated.render_root_component = route
.layouts
.first()
.cloned()
.unwrap_or_else(|| route.component.clone());
let product = build_application_publication_product_from_asm_v1(validated, composed)
.map_err(application_product_error)?;
let artifact_root = file_route_artifact_root_v1(&route.path);
for (path, bytes) in product.artifacts {
let path = PathBuf::from(&artifact_root).join(path);
if artifacts.insert(path.clone(), bytes).is_some() {
return Err(FileRoutePublicationErrorV1 {
code: "PSROUTE2004_FILE_ROUTE_ARTIFACT_COLLISION",
message: path.display().to_string(),
});
}
}
routes.push(FileRoutePublicationRouteV1 {
path: route.path.clone(),
entry_component_id: route.component.to_string(),
artifact_root,
layout_component_ids: route.layouts.iter().map(ToString::to_string).collect(),
});
}
routes.sort_by(|left, right| left.path.cmp(&right.path));
artifacts.insert(
PathBuf::from("route-loaders.plan.json"),
route_loader_plan_json_v1(&route_loader_plan).into_bytes(),
);
artifacts.insert(
PathBuf::from("route-server-actions.plan.json"),
route_server_action_plan_json_v1(&route_server_action_plan).into_bytes(),
);
let manifest = FileRoutePublicationManifestV1 {
schema_version: FILE_ROUTE_PUBLICATION_MANIFEST_SCHEMA_VERSION,
compiler_contract: FILE_ROUTE_PUBLICATION_COMPILER_CONTRACT_V1.into(),
profile: request.profile.as_str().into(),
routes,
artifacts: artifacts
.iter()
.map(|(path, bytes)| ApplicationPublicationArtifactV1 {
path: path.to_string_lossy().replace('\\', "/"),
digest: crate::platform::Digest::sha256(bytes).to_string(),
})
.collect(),
};
artifacts.insert(
PathBuf::from("file-routes.manifest.json"),
file_route_publication_manifest_json_v1(&manifest).into_bytes(),
);
Ok(FileRoutePublicationProductV1 {
manifest,
artifacts,
})
}
#[must_use]
pub fn file_route_publication_manifest_json_v1(value: &FileRoutePublicationManifestV1) -> String {
serde_json::to_string_pretty(value).expect("file-route publication manifest serializes") + "\n"
}
#[must_use]
pub fn file_route_artifact_root_v1(path: &str) -> String {
if path == "/" {
return "routes/root".into();
}
let segments = path
.trim_matches('/')
.split('/')
.map(|segment| {
if let Some(parameter) = segment.strip_prefix(':') {
format!("parameter-{parameter}")
} else {
format!("segment-{segment}")
}
})
.collect::<Vec<_>>();
format!("routes/{}", segments.join("/"))
}
/// Resolves one HTTP path using only the compiler-issued route publication
/// manifest. The returned artifact remains an opaque compiler byte path for
/// the host to serve.
#[must_use]
pub fn resolve_file_route_request_v1(
manifest: &FileRoutePublicationManifestV1,
request_path: &str,
) -> Option<FileRouteRequestTargetV1> {
resolve_file_route_request_match_v1(manifest, request_path).map(|match_| match_.target)
}
/// Resolves a request through compiler-issued route topology and retains the
/// selected route identity plus exact dynamic parameter values. It does not
/// execute application code or interpret query, header, or body data.
#[must_use]
pub fn resolve_file_route_request_match_v1(
manifest: &FileRoutePublicationManifestV1,
request_path: &str,
) -> Option<FileRouteRequestMatchV1> {
if !request_path.starts_with('/') || request_path.contains('?') || request_path.contains('#') {
return None;
}
let trailing_slash = request_path.ends_with('/');
let segments = request_path
.trim_matches('/')
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let route = manifest
.routes
.iter()
.filter_map(|route| {
let route_segments = route
.path
.trim_matches('/')
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
(segments.len() >= route_segments.len()
&& route_segments
.iter()
.zip(&segments)
.all(|(route, request)| {
route.strip_prefix(':').is_some_and(|_| !request.is_empty())
|| route == request
}))
.then_some((route, route_segments))
})
.max_by(|(_, left_segments), (_, right_segments)| {
route_match_score(left_segments).cmp(&route_match_score(right_segments))
})?;
let (route, route_segments) = route;
let parameters = route_segments
.iter()
.zip(&segments)
.filter_map(|(route_segment, request_segment)| {
route_segment
.strip_prefix(':')
.map(|name| (name.to_string(), (*request_segment).to_string()))
})
.collect::<BTreeMap<_, _>>();
let match_record = |target| FileRouteRequestMatchV1 {
route_path: route.path.clone(),
entry_component_id: route.entry_component_id.clone(),
parameters: parameters.clone(),
target,
};
if segments.len() == route_segments.len() {
if route.path != "/" && !trailing_slash {
return Some(match_record(FileRouteRequestTargetV1::Redirect {
location: format!("{request_path}/"),
}));
}
return Some(match_record(FileRouteRequestTargetV1::Artifact {
path: PathBuf::from(&route.artifact_root).join("index.html"),
}));
}
let suffix = &segments[route_segments.len()..];
suffix
.iter()
.all(|segment| is_safe_request_asset_segment(segment))
.then(|| {
match_record(FileRouteRequestTargetV1::Artifact {
path: PathBuf::from(&route.artifact_root).join(suffix.iter().collect::<PathBuf>()),
})
})
}
fn route_match_score(segments: &[&str]) -> (usize, usize) {
(
segments
.iter()
.filter(|segment| !segment.starts_with(':'))
.count(),
segments.len(),
)
}
fn is_safe_request_asset_segment(segment: &str) -> bool {
!segment.is_empty() && segment != "." && segment != ".." && !segment.contains('\\')
}
fn route_error(error: crate::RouteGraphError) -> FileRoutePublicationErrorV1 {
FileRoutePublicationErrorV1 {
code: error.code,
message: error.message,
}
}
fn application_request_error(
error: ApplicationPublicationRequestErrorV1,
) -> FileRoutePublicationErrorV1 {
FileRoutePublicationErrorV1 {
code: error.code,
message: error.message,
}
}
fn application_product_error(error: ApplicationPublicationErrorV1) -> FileRoutePublicationErrorV1 {
FileRoutePublicationErrorV1 {
code: error.code,
message: error.message,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn publishes_each_file_route_under_a_distinct_compiler_owned_root() {
let product = build_file_route_publication_v1(FileRoutePublicationRequestV1 {
configuration: crate::platform::WorkspaceConfiguration::default(),
sources: vec![
ApplicationPublicationSourceV1 {
logical_path: "app/routes/index.tsx".into(),
source: r#"@component() class Home extends Component { render() { return <main>Home</main>; } }"#.into(),
},
ApplicationPublicationSourceV1 {
logical_path: "app/routes/about.tsx".into(),
source: r#"@component() class About extends Component { render() { return <main>About</main>; } }"#.into(),
},
],
package_contracts: SemanticPackageResolutionTable::default(),
package_runtime_modules: SemanticPackageRuntimeModuleTable::default(),
profile: ApplicationPublicationProfileV1::Development,
output_root: "dist".into(),
})
.unwrap();
assert_eq!(product.manifest.routes.len(), 2);
let home =
String::from_utf8(product.artifacts[&PathBuf::from("routes/root/index.html")].clone())
.unwrap();
let about = String::from_utf8(
product.artifacts[&PathBuf::from("routes/segment-about/index.html")].clone(),
)
.unwrap();
assert!(home.contains(">Home</main>"));
assert!(!home.contains(">About</main>"));
assert!(about.contains(">About</main>"));
assert!(product
.artifacts
.contains_key(&PathBuf::from("file-routes.manifest.json")));
}
#[test]
fn publishes_a_route_through_its_compiler_composed_layout_root() {
let product = build_file_route_publication_v1(FileRoutePublicationRequestV1 {
configuration: crate::platform::WorkspaceConfiguration::default(),
sources: vec![
ApplicationPublicationSourceV1 {
logical_path: "app/layout.tsx".into(),
source: r#"
@component() class AppLayout extends Component {
@slot() children!: SlotContent;
render() { return <main><slot /></main>; }
}
"#
.into(),
},
ApplicationPublicationSourceV1 {
logical_path: "app/routes/index.tsx".into(),
source: r#"@component() class Home extends Component { render() { return <article>Home</article>; } }"#.into(),
},
],
package_contracts: SemanticPackageResolutionTable::default(),
package_runtime_modules: SemanticPackageRuntimeModuleTable::default(),
profile: ApplicationPublicationProfileV1::Development,
output_root: "dist".into(),
})
.expect("valid route layout publication");
let html =
String::from_utf8(product.artifacts[&PathBuf::from("routes/root/index.html")].clone())
.unwrap();
assert!(html.contains("<main"));
assert!(html.contains("<article"));
assert!(html.contains("Home"));
}
#[test]
fn publishes_an_exact_route_loader_handoff_plan_without_server_execution() {
let contract = crate::parse_semantic_package_contract(
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"}}}}"#,
)
.unwrap();
let mut contracts = SemanticPackageResolutionTable::default();
contracts.insert("post-service".into(), contract).unwrap();
let product = build_file_route_publication_v1(FileRoutePublicationRequestV1 {
configuration: crate::platform::WorkspaceConfiguration::default(),
sources: vec![ApplicationPublicationSourceV1 {
logical_path: "app/routes/posts/[slug].tsx".into(),
source: r#"
import { loadPost } from "post-service";
@component() class Post {
@loader("loadPost") post!: Resource<Post, NotFound>;
render() { return <article />; }
}
"#
.into(),
}],
package_contracts: contracts,
package_runtime_modules: SemanticPackageRuntimeModuleTable::default(),
profile: ApplicationPublicationProfileV1::Development,
output_root: "dist".into(),
})
.expect("route loader handoff publication");
let plan =
String::from_utf8(product.artifacts[&PathBuf::from("route-loaders.plan.json")].clone())
.unwrap();
assert!(plan.contains("post-service"));
assert!(plan.contains("route_parameters"));
assert!(plan.contains("max_age_seconds"));
assert!(!plan.contains("function loadPost"));
}
#[test]
fn publishes_an_exact_route_server_action_handoff_without_server_execution() {
let contract = crate::parse_semantic_package_contract(
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"}}}}"#,
)
.unwrap();
let mut contracts = SemanticPackageResolutionTable::default();
contracts.insert("post-service".into(), contract).unwrap();
let product = build_file_route_publication_v1(FileRoutePublicationRequestV1 {
configuration: crate::platform::WorkspaceConfiguration::default(),
sources: vec![ApplicationPublicationSourceV1 {
logical_path: "app/routes/posts/[slug].tsx".into(),
source: r#"
import { savePost } from "post-service";
@component() class Post {
@serverAction("savePost") save(): void {}
render() { return <form />; }
}
"#
.into(),
}],
package_contracts: contracts,
package_runtime_modules: SemanticPackageRuntimeModuleTable::default(),
profile: ApplicationPublicationProfileV1::Development,
output_root: "dist".into(),
})
.expect("route server-action handoff publication");
let plan = String::from_utf8(
product.artifacts[&PathBuf::from("route-server-actions.plan.json")].clone(),
)
.unwrap();
assert!(plan.contains("post-service"));
assert!(plan.contains("form_data"));
assert!(plan.contains("redirect"));
assert!(plan.contains("cold_fallback"));
assert!(!plan.contains("function savePost"));
}
#[test]
fn resolves_page_and_asset_paths_from_the_compiler_manifest() {
let manifest = FileRoutePublicationManifestV1 {
schema_version: 1,
compiler_contract: FILE_ROUTE_PUBLICATION_COMPILER_CONTRACT_V1.into(),
profile: "development".into(),
routes: vec![
FileRoutePublicationRouteV1 {
path: "/posts/:slug".into(),
entry_component_id: "component:post".into(),
artifact_root: "routes/segment-posts/parameter-slug".into(),
layout_component_ids: Vec::new(),
},
FileRoutePublicationRouteV1 {
path: "/posts/new".into(),
entry_component_id: "component:new".into(),
artifact_root: "routes/segment-posts/segment-new".into(),
layout_component_ids: Vec::new(),
},
],
artifacts: Vec::new(),
};
assert_eq!(
resolve_file_route_request_v1(&manifest, "/posts/new"),
Some(FileRouteRequestTargetV1::Redirect {
location: "/posts/new/".into()
})
);
assert_eq!(
resolve_file_route_request_v1(&manifest, "/posts/hello/runtime.js"),
Some(FileRouteRequestTargetV1::Artifact {
path: "routes/segment-posts/parameter-slug/runtime.js".into()
})
);
}
#[test]
fn retains_dynamic_parameter_facts_from_the_selected_compiler_route() {
let manifest = FileRoutePublicationManifestV1 {
schema_version: 1,
compiler_contract: FILE_ROUTE_PUBLICATION_COMPILER_CONTRACT_V1.into(),
profile: "development".into(),
routes: vec![FileRoutePublicationRouteV1 {
path: "/posts/:slug/comments/:commentId".into(),
entry_component_id: "component:comment".into(),
artifact_root: "routes/comment".into(),
layout_component_ids: Vec::new(),
}],
artifacts: Vec::new(),
};
let resolved =
resolve_file_route_request_match_v1(&manifest, "/posts/hello-world/comments/42/")
.expect("selected route");
assert_eq!(resolved.route_path, "/posts/:slug/comments/:commentId");
assert_eq!(resolved.entry_component_id, "component:comment");
assert_eq!(
resolved.parameters,
BTreeMap::from([
("slug".to_string(), "hello-world".to_string()),
("commentId".to_string(), "42".to_string()),
])
);
assert_eq!(
resolved.target,
FileRouteRequestTargetV1::Artifact {
path: "routes/comment/index.html".into(),
}
);
}
}