-
Notifications
You must be signed in to change notification settings - Fork 1
Add SSR app router registry and build-time route scanner #40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add SSR app router registry and build-time route scanner #40
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds a new SSR (Server-Side Rendering) app router system that discovers page routes at build time by scanning an app/ directory structure and makes them available to the SSR runtime through a registry.
Changes:
- Added new
rustapi-ssrcrate with build-time route scanning and runtime registry - Implemented route pattern normalization supporting static segments, dynamic parameters
[name], and route groups(group) - Created a matchit-based route registry with parameter extraction for SSR path matching
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| Cargo.toml | Registers the new rustapi-ssr crate in workspace members and dependencies |
| crates/rustapi-ssr/Cargo.toml | Defines the package manifest with dependencies on inventory, matchit, and thiserror |
| crates/rustapi-ssr/src/lib.rs | Exports the app_router module as the public API |
| crates/rustapi-ssr/src/app_router/mod.rs | Implements route registry with PageRoute inventory collection and matchit-based matching |
| crates/rustapi-ssr/build.rs | Scans app directory for page.rs files, normalizes routes, detects conflicts, and generates inventory submissions |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if let Ok(env_dir) = env::var("RUSTAPI_APP_DIR") { | ||
| return PathBuf::from(env_dir); |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The RUSTAPI_APP_DIR environment variable is read during build but there's no documentation or validation of its expected format. If a user sets this to an invalid path or uses a relative path, the behavior is undefined. Consider documenting this environment variable and validating that it points to an absolute path or properly resolving relative paths.
| if let Ok(env_dir) = env::var("RUSTAPI_APP_DIR") { | |
| return PathBuf::from(env_dir); | |
| // If set, RUSTAPI_APP_DIR points to the directory containing the app routes. | |
| // It may be an absolute path or a path relative to CARGO_MANIFEST_DIR. | |
| if let Ok(env_dir) = env::var("RUSTAPI_APP_DIR") { | |
| let trimmed = env_dir.trim(); | |
| if trimmed.is_empty() { | |
| panic!( | |
| "RUSTAPI_APP_DIR is set but empty; expected an absolute or relative path to the app directory" | |
| ); | |
| } | |
| let env_path = PathBuf::from(trimmed); | |
| let resolved = if env_path.is_absolute() { | |
| env_path | |
| } else { | |
| // Resolve relative paths against the manifest directory for deterministic behavior. | |
| manifest_dir.join(env_path) | |
| }; | |
| println!( | |
| "cargo:warning=Using RUSTAPI_APP_DIR={} resolved to {}", | |
| trimmed, | |
| resolved.display() | |
| ); | |
| return resolved; |
| emit_rerun_if_changed(&app_dir); | ||
| return; | ||
| } | ||
|
|
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The emit_rerun_if_changed function is called even when app_dir doesn't exist (line 21). If the directory doesn't exist, the function will print the rerun-if-changed directive for the non-existent directory path, then attempt to read it and fail silently in the loop. While not causing a build failure, this is redundant - the early return on line 22 should come before the emit_rerun_if_changed call on line 21.
| emit_rerun_if_changed(&app_dir); | |
| return; | |
| } | |
| return; | |
| } |
| #[derive(Debug, Clone, Copy)] | ||
| pub struct PageRoute { | ||
| pub pattern: &'static str, | ||
| pub source: &'static str, | ||
| } | ||
|
|
||
| inventory::collect!(PageRoute); | ||
|
|
||
| include!(concat!(env!("OUT_DIR"), "/app_router_routes.rs")); | ||
|
|
||
| #[derive(Debug, Error)] | ||
| pub enum RegistryError { | ||
| #[error("route registry build failed: {0}")] | ||
| Router(#[from] matchit::InsertError), | ||
| } | ||
|
|
||
| #[derive(Debug)] | ||
| pub struct MatchedRoute<'a> { | ||
| pub route: &'a PageRoute, | ||
| pub params: Vec<(&'a str, String)>, | ||
| } | ||
|
|
||
| #[derive(Debug)] | ||
| pub struct RouteRegistry { | ||
| router: Router<&'static PageRoute>, | ||
| } | ||
|
|
||
| impl RouteRegistry { | ||
| pub fn new() -> Result<Self, RegistryError> { | ||
| let mut router = Router::new(); | ||
| for route in inventory::iter::<PageRoute> { | ||
| router.insert(route.pattern, route)?; | ||
| } | ||
| Ok(Self { router }) | ||
| } | ||
|
|
||
| pub fn match_route(&self, path: &str) -> Option<MatchedRoute<'_>> { | ||
| let matched = self.router.at(path).ok()?; | ||
| let params = matched | ||
| .params | ||
| .iter() | ||
| .map(|(key, value)| (key, value.to_string())) | ||
| .collect(); | ||
| Some(MatchedRoute { | ||
| route: matched.value, | ||
| params, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| pub fn registry() -> Result<&'static RouteRegistry, RegistryError> { | ||
| static REGISTRY: OnceLock<RouteRegistry> = OnceLock::new(); | ||
| REGISTRY.get_or_try_init(RouteRegistry::new) | ||
| } | ||
|
|
||
| pub fn routes() -> impl Iterator<Item = &'static PageRoute> { | ||
| inventory::iter::<PageRoute> | ||
| } |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The public API lacks documentation. All public types (PageRoute, MatchedRoute, RouteRegistry, RegistryError) and functions (registry, routes, match_route) should have doc comments explaining their purpose, usage, and any important behavior. This is especially important for a library crate that will be consumed by other code.
| impl RouteRegistry { | ||
| pub fn new() -> Result<Self, RegistryError> { | ||
| let mut router = Router::new(); | ||
| for route in inventory::iter::<PageRoute> { | ||
| router.insert(route.pattern, route)?; | ||
| } | ||
| Ok(Self { router }) | ||
| } | ||
|
|
||
| pub fn match_route(&self, path: &str) -> Option<MatchedRoute<'_>> { | ||
| let matched = self.router.at(path).ok()?; | ||
| let params = matched | ||
| .params | ||
| .iter() | ||
| .map(|(key, value)| (key, value.to_string())) | ||
| .collect(); | ||
| Some(MatchedRoute { | ||
| route: matched.value, | ||
| params, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| pub fn registry() -> Result<&'static RouteRegistry, RegistryError> { | ||
| static REGISTRY: OnceLock<RouteRegistry> = OnceLock::new(); | ||
| REGISTRY.get_or_try_init(RouteRegistry::new) | ||
| } |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The RouteRegistry and route matching logic lack test coverage. Important behaviors to test include: registry initialization with multiple routes, route matching with various path patterns, parameter extraction from dynamic segments, handling of non-matching paths, and error cases during registry construction. Consider adding tests to verify these behaviors work correctly.
| if segment.is_empty() { | ||
| panic!("Empty path segment in {}", path.display()); | ||
| } | ||
|
|
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The collect_routes function doesn't validate or sanitize directory names before using them as route segments. Malicious or unusual directory names containing special characters (beyond the handled brackets and parentheses) could potentially create unexpected route patterns. Consider adding validation to ensure directory names contain only safe characters for URL paths.
| if !segment | |
| .chars() | |
| .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '~') | |
| { | |
| panic!( | |
| "Invalid static segment '{}' in {}", | |
| segment, | |
| path.display() | |
| ); | |
| } |
| "Route conflict: '{}' from {} conflicts with {}", | ||
| route.pattern, existing.source, route.source |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error message incorrectly shows 'route.pattern' for the existing route when it should show 'existing.pattern'. This makes the error message confusing as it displays the same pattern twice instead of showing both conflicting patterns.
| "Route conflict: '{}' from {} conflicts with {}", | |
| route.pattern, existing.source, route.source | |
| "Route conflict: '{}' from {} conflicts with '{}' from {}", | |
| existing.pattern, existing.source, route.pattern, route.source |
|
|
||
| [lib] | ||
| path = "src/lib.rs" | ||
|
|
||
| build = "build.rs" | ||
|
|
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The build script is specified outside the [lib] section, but according to Cargo.toml specification, the 'build' field should be at the package level, not inside the [lib] section. This may cause the build script to be ignored or cause a configuration error.
| [lib] | |
| path = "src/lib.rs" | |
| build = "build.rs" | |
| build = "build.rs" | |
| [lib] | |
| path = "src/lib.rs" |
| fn collect_routes(app_dir: &Path, current: &Path, routes: &mut Vec<RouteEntry>) { | ||
| let entries = match fs::read_dir(current) { | ||
| Ok(entries) => entries, | ||
| Err(_) => return, | ||
| }; | ||
|
|
||
| for entry in entries.flatten() { | ||
| let path = entry.path(); | ||
| if path.is_dir() { | ||
| collect_routes(app_dir, &path, routes); | ||
| continue; | ||
| } | ||
|
|
||
| if path.file_name().and_then(|s| s.to_str()) != Some("page.rs") { | ||
| continue; | ||
| } | ||
|
|
||
| let relative = match path.strip_prefix(app_dir) { | ||
| Ok(path) => path, | ||
| Err(_) => continue, | ||
| }; | ||
|
|
||
| let segments: Vec<String> = relative | ||
| .components() | ||
| .filter_map(|component| component.as_os_str().to_str().map(|s| s.to_string())) | ||
| .collect(); | ||
|
|
||
| if segments.is_empty() { | ||
| continue; | ||
| } | ||
|
|
||
| let mut route_segments = Vec::new(); | ||
| for segment in &segments[..segments.len() - 1] { | ||
| if let Some(stripped) = segment.strip_prefix('(').and_then(|s| s.strip_suffix(')')) { | ||
| if stripped.is_empty() { | ||
| panic!("Invalid route group segment '()' in {}", path.display()); | ||
| } | ||
| continue; | ||
| } | ||
|
|
||
| if let Some(param) = segment.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { | ||
| if param.is_empty() { | ||
| panic!("Invalid dynamic segment '[]' in {}", path.display()); | ||
| } | ||
| if !param | ||
| .chars() | ||
| .all(|c| c.is_ascii_alphanumeric() || c == '_') | ||
| { | ||
| panic!("Invalid dynamic segment '[{}]' in {}", param, path.display()); | ||
| } | ||
| route_segments.push(format!("{{{}}}", param)); | ||
| continue; | ||
| } | ||
|
|
||
| if segment.is_empty() { | ||
| panic!("Empty path segment in {}", path.display()); | ||
| } | ||
|
|
||
| route_segments.push(segment.to_string()); | ||
| } | ||
|
|
||
| let pattern = if route_segments.is_empty() { | ||
| "/".to_string() | ||
| } else { | ||
| format!("/{}", route_segments.join("/")) | ||
| }; | ||
|
|
||
| let normalized = if route_segments.is_empty() { | ||
| "/".to_string() | ||
| } else { | ||
| let mut normalized_segments = Vec::new(); | ||
| for seg in &route_segments { | ||
| if seg.starts_with('{') && seg.ends_with('}') { | ||
| normalized_segments.push("{}"); | ||
| } else { | ||
| normalized_segments.push(seg); | ||
| } | ||
| } | ||
| format!("/{}", normalized_segments.join("/")) | ||
| }; | ||
|
|
||
| let source = format!("app/{}", segments.join("/")); | ||
| routes.push(RouteEntry { | ||
| pattern, | ||
| normalized, | ||
| source, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| fn validate_routes(routes: &[RouteEntry]) { | ||
| let mut seen = std::collections::HashMap::new(); | ||
| for route in routes { | ||
| if let Some(existing) = seen.insert(route.normalized.clone(), route) { | ||
| panic!( | ||
| "Route conflict: '{}' from {} conflicts with {}", | ||
| route.pattern, existing.source, route.source | ||
| ); | ||
| } | ||
| } | ||
| } |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The route scanning and normalization logic in the build script lacks test coverage. This includes critical behaviors like dynamic segment parsing ([param] -> {param}), route group filtering ((group)), conflict detection, and pattern normalization. Consider adding unit tests for the helper functions (collect_routes, validate_routes, resolve_app_dir) to ensure edge cases are handled correctly.
| println!("cargo:rerun-if-changed={}", app_dir.display()); | ||
| let mut stack = vec![app_dir.to_path_buf()]; | ||
| while let Some(dir) = stack.pop() { | ||
| let entries = match fs::read_dir(&dir) { | ||
| Ok(entries) => entries, | ||
| Err(_) => continue, | ||
| }; | ||
|
|
||
| for entry in entries.flatten() { | ||
| let path = entry.path(); | ||
| if path.is_dir() { | ||
| stack.push(path); | ||
| } else { | ||
| println!("cargo:rerun-if-changed={}", path.display()); | ||
| } | ||
| } | ||
| } |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The emit_rerun_if_changed function performs a full directory traversal and emits a cargo directive for every file and directory. For large app directories with many files, this could generate an excessive number of rerun directives and slow down incremental builds. Consider emitting only the directory path or using a more targeted approach that watches only relevant files (e.g., page.rs files).
| println!("cargo:rerun-if-changed={}", app_dir.display()); | |
| let mut stack = vec![app_dir.to_path_buf()]; | |
| while let Some(dir) = stack.pop() { | |
| let entries = match fs::read_dir(&dir) { | |
| Ok(entries) => entries, | |
| Err(_) => continue, | |
| }; | |
| for entry in entries.flatten() { | |
| let path = entry.path(); | |
| if path.is_dir() { | |
| stack.push(path); | |
| } else { | |
| println!("cargo:rerun-if-changed={}", path.display()); | |
| } | |
| } | |
| } | |
| // Instruct Cargo to rerun this build script if anything under `app_dir` changes. | |
| // Cargo treats directory paths recursively, so we don't need to walk the tree ourselves. | |
| println!("cargo:rerun-if-changed={}", app_dir.display()); |
| pub fn match_route(&self, path: &str) -> Option<MatchedRoute<'_>> { | ||
| let matched = self.router.at(path).ok()?; | ||
| let params = matched | ||
| .params | ||
| .iter() | ||
| .map(|(key, value)| (key, value.to_string())) | ||
| .collect(); | ||
| Some(MatchedRoute { | ||
| route: matched.value, | ||
| params, | ||
| }) | ||
| } |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The match_route method returns Option which loses information about why matching failed. Consider returning Result<MatchedRoute, MatchError> instead, where MatchError could distinguish between "no route found" and other potential matching errors from the underlying matchit router. This would provide better diagnostics for callers.
Motivation
app/pages at build time and makes routes available to the SSR runtime.[param]segments and(group)route groups) are normalized and validated during compilation to catch conflicts early.Description
crates/rustapi-ssrand register it in the workspaceCargo.toml.build.rsthat scans theapp/directory (orRUSTAPI_APP_DIR) forpage.rsfiles, maps segments into route patterns (static segments, dynamic[name] -> {name}, and ignoring(group)segments), normalizes patterns, detects conflicts at compile time, and writes inventory submissions toOUT_DIR/app_router_routes.rs.app/tree viacargo:rerun-if-changedusingemit_rerun_if_changedso the build script reruns when app files change.src/app_router/mod.rswith aPageRoutetype,inventorycollection and an in-processRouteRegistrybacked bymatchit::Routerthat exposesregistry()andmatch_route()(returnsMatchedRoutewith captured params) for the SSR runtime to query.Testing
Codex Task