diff --git a/docs/runtime.md b/docs/runtime.md new file mode 100644 index 0000000..73032be --- /dev/null +++ b/docs/runtime.md @@ -0,0 +1,37 @@ +# Runtime behavior + +## Routing + +Routes are flattened across modules and nested `location` blocks and compiled +into a deterministic table for each HTTP method. Segments are compared from left +to right: literal > named parameter > wildcard. A literal route therefore wins +over a generic route regardless of file discovery or declaration order. + +Paths must match all segments. `*` matches exactly one nonempty segment, not a +whole subtree. Trailing and repeated slashes are ignored. Query strings do not +participate in route selection. Parameters are raw URI strings: their declared +types are not checked or converted, and percent escapes are not decoded. + +Equivalent routes (including parameters with different names or type annotations) +are rejected with both source locations. Overlapping routes such as +`/fixed/(x: String)` and `/(y: String)/fixed` produce a warning; left-to-right +precedence selects the former for `/fixed/fixed`. Equal-ranked patterns use +lexical path, source filename, and line order as a stable fallback. Methods are +independent; there is no implicit GET-to-HEAD fallback or automatic 405 response. +Missing routes return 404. + +## Development reload + +Before every development request, WebX checks the `.wx`/`.webx` source tree by +content, as well as responding to filesystem notifications. A completed valid +save is visible on the next request without restarting or sleeping for a watcher. +This includes new files, deletions, and editor atomic-save renames. Production +mode does not reload source modules. Static files are read on each call in either +mode; no browser auto-refresh is provided. + +Parsing, route validation, and changed global-block initialization must all +succeed before the new module set and route table become active. A failed edit +reports a diagnostic and retains the complete last good snapshot. HTML/route-only +edits preserve module globals. Changing a module's `global` block resets that +module's state; removal discards it. Development checks read the source tree on +each request and are intended for correctness and convenience, not throughput. diff --git a/src/analysis/routes.rs b/src/analysis/routes.rs index 8bbe52b..18ae282 100644 --- a/src/analysis/routes.rs +++ b/src/analysis/routes.rs @@ -1,137 +1,175 @@ -use colored::*; - -use std::collections::HashMap; +use std::cmp::Ordering; use crate::{ engine::runtime::WXRuntimeError, - file::webx::{WXInfoField, WXModule, WXRoute, WXScope, WXUrlPath, WXROOT_PATH}, - reporting::{ - error::{ - exit_error, format_info_field, DateTimeSpecifier, ERROR_DUPLICATE_ROUTE, - ERROR_INVALID_ROUTE, - }, - route::print_route, + file::webx::{WXModule, WXRoute, WXScope, WXUrlPath, WXUrlPathSegment, WXROOT_PATH}, + reporting::error::{ + exit_error, format_info_field, DateTimeSpecifier, ERROR_DUPLICATE_ROUTE, + ERROR_INVALID_ROUTE, }, }; -pub type FlatRoutes = HashMap<(WXRoute, WXUrlPath), Vec>; +// Keep every declaration, including duplicates in different location scopes. +pub type FlatRoutes = Vec<(WXRoute, WXUrlPath)>; -fn flatten_scopes( - module_name: String, - scope: &WXScope, - path_prefix: WXUrlPath, - routes: &mut FlatRoutes, -) { - for route in scope.routes.iter() { - let flat_path = path_prefix.combine(&route.path); - let route_key = (route.clone(), flat_path); - if let std::collections::hash_map::Entry::Vacant(entry) = routes.entry(route_key.clone()) { - entry.insert(vec![route.info.clone()]); - } else { - routes.get_mut(&route_key).unwrap().push(route.info.clone()); - } +fn flatten_scopes(scope: &WXScope, prefix: &WXUrlPath, routes: &mut FlatRoutes) { + for route in &scope.routes { + routes.push((route.clone(), prefix.combine(&route.path))); } - for sub_scope in scope.scopes.iter() { - let sub_scope_path = path_prefix.combine(&sub_scope.path); - flatten_scopes(module_name.clone(), sub_scope, sub_scope_path, routes); + for child in &scope.scopes { + flatten_scopes(child, &prefix.combine(&child.path), routes); } } pub fn extract_flat_routes(modules: &[WXModule]) -> FlatRoutes { - let mut routes = HashMap::new(); - for module in modules.iter() { - flatten_scopes( - module.path.module_name(), - &module.scope, - WXROOT_PATH, - &mut routes, - ); + let mut routes = Vec::new(); + for module in modules { + flatten_scopes(&module.scope, &WXROOT_PATH, &mut routes); } + routes.sort_by(|(a, ap), (b, bp)| { + a.method + .as_str() + .cmp(b.method.as_str()) + .then_with(|| compare_specificity(ap, bp)) + .then_with(|| ap.to_string().cmp(&bp.to_string())) + .then_with(|| a.info.path.inner.cmp(&b.info.path.inner)) + .then_with(|| a.info.line.cmp(&b.info.line)) + }); routes } -pub fn extract_duplicate_routes(routes: &FlatRoutes) -> Vec { - routes - .iter() - .filter(|(_, modules)| modules.len() > 1) - .map(|((route, path), modules)| { - let locations = modules.iter().map(format_info_field).collect::>(); - format!( - "Route {} is defined in modules:\n - {}", - print_route(&route.method, path), - locations.join("\n - ") - ) - }) - .collect() +fn rank(segment: &WXUrlPathSegment) -> u8 { + match segment { + WXUrlPathSegment::Literal(_) => 2, + WXUrlPathSegment::Parameter(_) => 1, + WXUrlPathSegment::Regex(_, _) => 0, + } } -pub fn analyze_duplicate_routes(modules: &[WXModule]) -> Result { - let routes = extract_flat_routes(modules); - let duplicate_routes = extract_duplicate_routes(&routes); - if !duplicate_routes.is_empty() { - return Err(WXRuntimeError { - code: ERROR_DUPLICATE_ROUTE, - message: format!( - "Duplicate routes detected:\n - {}", - duplicate_routes.join("\n - ") - ), - }); - } - Ok(routes) +/// Most specific first: compare segments from left to right, then length. +/// Matching requires equal lengths; the length comparison only orders the table. +pub fn compare_specificity(a: &WXUrlPath, b: &WXUrlPath) -> Ordering { + b.0.iter().map(rank).cmp(a.0.iter().map(rank)) } -fn extract_invalid_routes(routes: &FlatRoutes) -> Vec { - routes - .iter() - .filter(|((route, _), _)| match route.method { - hyper::Method::GET | hyper::Method::DELETE => route.body_format.is_some(), - hyper::Method::POST | hyper::Method::PUT => route.body_format.is_none(), +fn same_shape(a: &WXUrlPath, b: &WXUrlPath) -> bool { + a.segments() == b.segments() + && a.0.iter().zip(&b.0).all(|(a, b)| match (a, b) { + (WXUrlPathSegment::Literal(a), WXUrlPathSegment::Literal(b)) => a == b, + (WXUrlPathSegment::Parameter(_), WXUrlPathSegment::Parameter(_)) => true, + (WXUrlPathSegment::Regex(_, a), WXUrlPathSegment::Regex(_, b)) => a == b, _ => false, }) - .map(|((route, path), info)| { - format!( - "Route {} {} specify {}, but is not a POST or PUT endpoint. {}", - route.method.to_string().green(), - path.to_string().yellow(), - route.body_format.as_ref().unwrap().to_string().red(), - format_info_field(info.first().unwrap()), - ) +} + +fn may_overlap(a: &WXUrlPath, b: &WXUrlPath) -> bool { + a.segments() == b.segments() + && a.0.iter().zip(&b.0).all(|(a, b)| match (a, b) { + (WXUrlPathSegment::Literal(a), WXUrlPathSegment::Literal(b)) => a == b, + (WXUrlPathSegment::Literal(text), WXUrlPathSegment::Regex(_, pattern)) + | (WXUrlPathSegment::Regex(_, pattern), WXUrlPathSegment::Literal(text)) => { + pattern == "*" + || regex::Regex::new(&format!("^(?:{})$", pattern)) + .is_ok_and(|re| re.is_match(text)) + } + _ => true, }) - .collect() } -/// Analyze the implementation of routes in a list of WebX modules. -/// If an invalid route is detected, an error is reported and the program exits. -/// Invalid routes include: -/// - bad combinations of route methods and request body format types (e.g. GET + body) -pub fn analyze_invalid_routes(modules: &[WXModule]) -> Result<(), WXRuntimeError> { +pub fn extract_duplicate_routes(routes: &FlatRoutes) -> Vec { + let mut diagnostics = Vec::new(); + for (i, (a, ap)) in routes.iter().enumerate() { + for (b, bp) in &routes[i + 1..] { + if a.method == b.method && same_shape(ap, bp) { + diagnostics.push(format!("Duplicate/ambiguous route {} {} at {} conflicts with {} at {} (parameter names and types do not distinguish routes)", + a.method, ap, format_info_field(&a.info), bp, format_info_field(&b.info))); + } + } + } + diagnostics +} + +/// Warn about overlaps that are not a simple specific-route/fallback pair. +pub fn ambiguous_route_warnings(routes: &FlatRoutes) -> Vec { + let mut diagnostics = Vec::new(); + for (i, (a, ap)) in routes.iter().enumerate() { + for (b, bp) in &routes[i + 1..] { + let a_more = ap.0.iter().zip(&bp.0).any(|(a, b)| rank(a) > rank(b)); + let b_more = ap.0.iter().zip(&bp.0).any(|(a, b)| rank(b) > rank(a)); + if a.method == b.method + && !same_shape(ap, bp) + && may_overlap(ap, bp) + && ((a_more && b_more) || compare_specificity(ap, bp) == Ordering::Equal) + { + diagnostics.push(format!("Ambiguous routes {} {} at {} and {} at {}; precedence is left-to-right literal > parameter > wildcard, then lexical path/source order", + a.method, ap, format_info_field(&a.info), bp, format_info_field(&b.info))); + } + } + } + diagnostics +} + +pub fn analyze_duplicate_routes(modules: &[WXModule]) -> Result { let routes = extract_flat_routes(modules); - let invalid_routes = extract_invalid_routes(&routes); - if !invalid_routes.is_empty() { + let duplicates = extract_duplicate_routes(&routes); + if !duplicates.is_empty() { return Err(WXRuntimeError { - code: ERROR_INVALID_ROUTE, - message: format!( - "Invalid routes detected:\n - {}", - invalid_routes.join("\n - ") - ), + code: ERROR_DUPLICATE_ROUTE, + message: duplicates.join("\n"), }); } - Ok(()) + Ok(routes) } -fn exit_on_err(result: Result) { - if let Err(err) = result { - exit_error(err.message, err.code, DateTimeSpecifier::None); +pub fn analyze_invalid_routes(modules: &[WXModule]) -> Result<(), WXRuntimeError> { + let mut errors = Vec::new(); + for (route, path) in extract_flat_routes(modules) { + // A body declaration is optional for write methods. Never unwrap a missing declaration. + if matches!( + route.method, + hyper::Method::GET | hyper::Method::HEAD | hyper::Method::DELETE + ) && route.body_format.is_some() + { + errors.push(format!( + "{} {} cannot declare a request body ({})", + route.method, + path, + format_info_field(&route.info) + )); + } + for segment in &path.0 { + if let WXUrlPathSegment::Regex(_, pattern) = segment { + if pattern != "*" && regex::Regex::new(&format!("^(?:{})$", pattern)).is_err() { + errors.push(format!( + "Invalid route regex {:?} at {}", + pattern, + format_info_field(&route.info) + )); + } + } + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(WXRuntimeError { + code: ERROR_INVALID_ROUTE, + message: errors.join("\n"), + }) } } pub fn analyze_module_routes(modules: &[WXModule]) { - exit_on_err(analyze_duplicate_routes(modules)); - exit_on_err(analyze_invalid_routes(modules)); + if let Err(err) = verify_model_routes(modules) { + exit_error(err.message, err.code, DateTimeSpecifier::None); + } } pub fn verify_model_routes(modules: &[WXModule]) -> Result { let routes = analyze_duplicate_routes(modules)?; analyze_invalid_routes(modules)?; + for message in ambiguous_route_warnings(&routes) { + eprintln!("Route warning: {}", message); + } Ok(routes) } diff --git a/src/engine/filewatcher.rs b/src/engine/filewatcher.rs index a32a0d2..a1b6023 100644 --- a/src/engine/filewatcher.rs +++ b/src/engine/filewatcher.rs @@ -1,142 +1,53 @@ -use notify::{self, Error, Event, Watcher}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::Sender; -use std::sync::Arc; -use std::time::Instant; +use notify::{Event, EventKind, Watcher}; +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::Sender, + Arc, + }, + time::Duration, +}; -use crate::engine::runtime::WXRuntimeMessage; -use crate::file::parser::parse_webx_file; -use crate::file::webx::WXModulePath; -use crate::reporting::debug::info; -use crate::reporting::warning::warning; -use crate::runner::WXMode; -use crate::timeout_duration; +use crate::{ + engine::runtime::WXRuntimeMessage, + reporting::{debug::info, warning::warning}, + runner::WXMode, +}; -struct FSWEvent { - pub kind: notify::EventKind, - pub path: WXModulePath, - pub timestamp: Instant, - is_empty_state: bool, -} - -impl FSWEvent { - fn new(kind: notify::EventKind, path: &Path) -> Self { - Self { - kind, - path: WXModulePath::new(path.to_path_buf()), - timestamp: Instant::now(), - is_empty_state: false, - } - } - - fn empty() -> Self { - Self { - kind: notify::EventKind::default(), - path: WXModulePath::new(PathBuf::default()), - timestamp: Instant::now(), - is_empty_state: true, - } - } - - fn is_duplicate(&self, earlier: &Self) -> bool { - if self.is_empty_state || earlier.is_empty_state { - return false; - } - const EPSILON: u128 = 100; // ms - self.kind == earlier.kind - && self.path == earlier.path - && self.timestamp.duration_since(earlier.timestamp).as_millis() < EPSILON - } -} - -pub struct WXFileWatcher {} +pub struct WXFileWatcher; impl WXFileWatcher { - /// Registers the file watcher thread pub fn run( mode: WXMode, source_root: PathBuf, rt_tx: Sender, running: Arc, ) { - let mut last_event: FSWEvent = FSWEvent::empty(); - let mut watcher = notify::recommended_watcher(move |res: Result| { - match res { - Ok(event) => { - match event.kind { - notify::EventKind::Create(_) => { - let event = FSWEvent::new(event.kind, &event.paths[0]); - if !event.is_duplicate(&last_event) { - match parse_webx_file(&event.path.inner) { - Ok(module) => { - if let Err(err) = rt_tx.send(WXRuntimeMessage::New(module)) - { - warning( - mode, - format!( - "(FileWatcher) Error send New module: {}", - err - ), - ) - } - } - Err(err) => { - warning(mode, format!("(FileWatcher) Error: {:?}", err)) - } - } - } - last_event = event; // Update last event - } - notify::EventKind::Modify(_) => { - let event = FSWEvent::new(event.kind, &event.paths[0]); - if !event.is_duplicate(&last_event) { - match parse_webx_file(&event.path.inner) { - Ok(module) => { - if let Err(err) = rt_tx.send(WXRuntimeMessage::Swap(module)) - { - warning( - mode, - format!( - "(FileWatcher) Error send Swap module: {}", - err - ), - ) - } - } - Err(err) => { - warning(mode, format!("(FileWatcher) Error: {:?}", err)) - } - } - } - last_event = event; // Update last event - } - notify::EventKind::Remove(_) => { - let event = FSWEvent::new(event.kind, &event.paths[0]); - if !event.is_duplicate(&last_event) { - rt_tx - .send(WXRuntimeMessage::Remove(event.path.clone())) - .unwrap(); - } - last_event = event; // Update last event - } - _ => (), + let mut watcher = + notify::recommended_watcher(move |result: Result| { + match result { + Ok(event) + if matches!( + event.kind, + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) + ) => + { + // Events can contain both sides of a rename or a directory. The + // runtime reconciles the tree; never parse a half-written event. + let _ = rt_tx.send(WXRuntimeMessage::Reload); } + Err(err) => warning(mode, format!("File watcher: {}", err)), + _ => {} } - Err(err) => warning(mode, format!("watch error: {:?}", err)), - } - }) - .unwrap(); + }) + .expect("Failed to create file watcher"); watcher .watch(&source_root, notify::RecursiveMode::Recursive) - .unwrap(); + .expect("Failed to watch source directory"); info(mode, "Hot reloading is enabled."); - loop { - if !running.load(Ordering::SeqCst) { - // println!("Shutting down file watcher..."); - break; - } - std::thread::sleep(timeout_duration(mode)); + while running.load(Ordering::SeqCst) { + std::thread::sleep(Duration::from_millis(50)); } } } diff --git a/src/engine/runtime.rs b/src/engine/runtime.rs index 2a2021f..0e5b7dc 100644 --- a/src/engine/runtime.rs +++ b/src/engine/runtime.rs @@ -2,7 +2,7 @@ use std::{ collections::HashMap, fmt::Display, net::SocketAddr, - path::Path, + path::{Path, PathBuf}, rc::Rc, sync::{ atomic::{AtomicBool, Ordering}, @@ -19,16 +19,12 @@ use hyper::body::Bytes; use crate::{ analysis::routes::{verify_model_routes, FlatRoutes}, + file::parser::parse_webx_source, file::webx::{ WXBody, WXBodyType, WXModule, WXModulePath, WXRouteHandlerCall, WXTypedIdentifier, WXUrlPath, WXUrlPathSegment, }, - reporting::{ - debug::info, - error::{error_code, exit_error}, - route::print_route, - warning::warning, - }, + reporting::{debug::info, error::error_code, route::print_route, warning::warning}, runner::WXMode, timeout_duration, }; @@ -189,8 +185,7 @@ impl WXRouteHandlerCall { #[derive(Debug, Clone, PartialEq)] pub enum WXPathResolution { None, - Perfect(WXRTContext), - Partial(WXRTContext), + Perfect(HashMap), } impl WXUrlPath { @@ -202,56 +197,33 @@ impl WXUrlPath { .collect::>() } - pub fn matches<'a, 'b: 'a>(&self, url: &hyper::Uri) -> WXPathResolution { - let url = WXUrlPath::get_url_segments(url); - let url_count = url.len(); - // dbg!(url.clone().collect::>(), url_count, self.segments()); - let mut bindings = WXRTContext::new(); - let mut isolate = v8::Isolate::new(Default::default()); - let mut scope = v8::HandleScope::new(&mut isolate); - - let match_segment = |(pattern, part): (&WXUrlPathSegment, &&str)| -> bool { + pub fn matches(&self, url: &hyper::Uri) -> WXPathResolution { + let parts = WXUrlPath::get_url_segments(url); + if self.segments() != parts.len() { + return WXPathResolution::None; + } + let mut bindings = HashMap::new(); + for (pattern, part) in self.0.iter().zip(parts) { match pattern { - WXUrlPathSegment::Literal(literal) => literal.as_str() == *part, - WXUrlPathSegment::Parameter(WXTypedIdentifier { name, type_: _ }) => { - // TODO: Check type. - let js_value: Local<'_, Value> = - v8::String::new(&mut scope, part).unwrap().into(); - let js_value: Global = Global::new(&mut scope, js_value); - bindings.bind(name, js_value); - true + WXUrlPathSegment::Literal(literal) if literal != part => { + return WXPathResolution::None } - WXUrlPathSegment::Regex(regex_name, regex) => { - let re = regex::Regex::new(regex).unwrap(); - if re.is_match(part) { - let js_value: Local<'_, Value> = - v8::String::new(&mut scope, part).unwrap().into(); - let js_value: Global = Global::new(&mut scope, js_value); - bindings.bind(regex_name, js_value); - true - } else { - false + WXUrlPathSegment::Parameter(WXTypedIdentifier { name, .. }) => { + bindings.insert(name.clone(), part.to_owned()); + } + WXUrlPathSegment::Regex(name, pattern) => { + if pattern != "*" + && !regex::Regex::new(&format!("^(?:{})$", pattern)) + .is_ok_and(|re| re.is_match(part)) + { + return WXPathResolution::None; } + bindings.insert(name.clone(), part.to_owned()); } + _ => {} } - }; - - if self.segments() == url_count { - if self.0.iter().zip(&url).all(match_segment) { - return WXPathResolution::Perfect(bindings); - } - } else if self.segments() > url_count - && self - .0 - .iter() - .zip(url.iter().chain(std::iter::repeat(&""))) - .all(match_segment) - && url_count == self.segments() - 1 - { - return WXPathResolution::Partial(bindings); } - - WXPathResolution::None + WXPathResolution::Perfect(bindings) } } @@ -438,7 +410,7 @@ impl WXRTRoute { } } -type WXMethodMapInner = HashMap; +type WXMethodMapInner = Vec<(WXUrlPath, WXRTRoute)>; type WXRouteMapInner = HashMap; /// This is a map of all routes in the project. @@ -458,8 +430,8 @@ impl WXRouteMap { let routes: FlatRoutes = verify_model_routes(modules)?; let mut route_map: WXRouteMapInner = HashMap::new(); // Insert all routes into each method map category. - for ((route, path), _) in routes { - route_map.entry(route.method.clone()).or_default().insert( + for (route, path) in routes { + route_map.entry(route.method.clone()).or_default().push(( path.clone(), WXRTRoute { module_path: route.info.path, @@ -467,7 +439,7 @@ impl WXRouteMap { pre_handlers: route.pre_handlers, post_handlers: route.post_handlers, }, - ); + )); } Ok(WXRouteMap(route_map)) } @@ -482,36 +454,21 @@ impl WXRouteMap { &self, method: &hyper::Method, path: &hyper::Uri, - ) -> Option<(&WXUrlPath, WXRTContext, &WXRTRoute)> { - let routes = self.0.get(method)?; - // Sort all routes by path length in descending order. - // This is required to ensure that the most specific routes are matched first. - let mut routes: Vec<(&WXUrlPath, &WXRTRoute)> = routes.iter().collect(); - routes.sort_by(|(a, _), (b, _)| b.segments().cmp(&a.segments())); - // Go through all routes and try to match the path. - let mut best_match = None; - for (route_path, route) in routes { - match route_path.matches(path) { - WXPathResolution::None => continue, - WXPathResolution::Perfect(bindings) => { - best_match = Some((route_path, bindings, route)); - break; - } - WXPathResolution::Partial(bindings) => { - best_match = Some((route_path, bindings, route)); - } + ) -> Option<(&WXUrlPath, HashMap, &WXRTRoute)> { + // The table is sorted once at compilation, independently of hash iteration. + for (route_path, route) in self.0.get(method)? { + if let WXPathResolution::Perfect(bindings) = route_path.matches(path) { + return Some((route_path, bindings, route)); } } - best_match + None } } /// Channel message for the runtime. pub enum WXRuntimeMessage { Shutdown, - New(WXModule), - Swap(WXModule), - Remove(WXModulePath), + Reload, ExecuteRoute { request: hyper::Request, addr: SocketAddr, @@ -523,12 +480,14 @@ pub enum WXRuntimeMessage { #[derive(Clone)] pub struct WXRuntimeInfo { pub project_root: Box, + pub source_root: PathBuf, } impl WXRuntimeInfo { pub fn new(project_root: &Path) -> Self { WXRuntimeInfo { project_root: project_root.to_path_buf().into_boxed_path(), + source_root: project_root.to_path_buf(), } } } @@ -538,20 +497,11 @@ pub struct WXRuntime { mode: WXMode, info: WXRuntimeInfo, source_modules: Vec, + last_sources: Option>, messages: Receiver, routes: WXRouteMap, - /// A WebX TypeScript runtime. - /// - /// ## Hot-swapping - /// These are persistent between hot-swapping modules in dev mode. - /// They are only created or destroyed when modules are added or removed. - /// This allows us to keep the state of the application between hot-swaps. - /// - /// ## Persistent state - /// Each JS runtime maintains a JavaScript execution context, - /// which means that it keeps track of its own persistent state, variables, - /// functions, and other constructs will persist between script executions - /// as long as they are run in the same runtime instance. + /// Module globals persist across requests and HTML-only edits. A changed + /// global block replaces its module runtime only after successful validation. modules: HashMap, } @@ -559,6 +509,7 @@ impl WXRuntime { pub fn new(rx: Receiver, mode: WXMode, info: WXRuntimeInfo) -> Self { WXRuntime { source_modules: Vec::new(), + last_sources: None, routes: WXRouteMap::new(), messages: rx, mode, @@ -576,6 +527,8 @@ impl WXRuntime { /// - trigger a module hot-swap in `dev` mode. pub fn load_modules(&mut self, modules: Vec) { modules.into_iter().for_each(|m| self.load_module(m)); + // The first development request rechecks the exact on-disk snapshot. + self.last_sources = None; } /// Load a single module into the runtime. @@ -589,59 +542,94 @@ impl WXRuntime { /// Only call this function once per module. /// This should **NOT** be called when hot-swapping modules. pub fn load_module(&mut self, module: WXModule) { - let rt = self.new_module_js_runtime(&module); + let rt = self + .new_module_js_runtime(&module) + .expect("Failed to initialize module"); self.modules.insert(module.path.clone(), rt); self.source_modules.push(module); } - fn remove_module(&mut self, module_path: &WXModulePath) { - self.modules.remove(module_path); - self.source_modules.retain(|m| m.path != *module_path); - } - - /// Initialize the JavaScript runtime with the stdlib. - fn new_js_runtime(&mut self) -> JsRuntime { + /// Build a candidate runtime without disturbing the currently serving module. + fn new_module_js_runtime(&self, module: &WXModule) -> Result { let mut rt = JsRuntime::new(RuntimeOptions { module_loader: Some(Rc::new(deno_core::FsModuleLoader)), - // extensions: vec![stdlib::init()], ..Default::default() }); - // Load WebX Standard Library - if let Err(err) = rt.execute_script( + rt.execute_script( "[webx stdlib]", deno_core::FastString::Static(stdlib::JAVASCRIPT), - ) { - exit_error( - format!("Failed to execute stdlib:\n{}", err), - 500, - self.mode.date_specifier(), - ); - } - info(self.mode, "Initialized stdlib"); - rt - } - - /// Initialize the module and execute the global scope - fn new_module_js_runtime(&mut self, module: &WXModule) -> JsRuntime { - let mut rt = self.new_js_runtime(); - if let Err(err) = - rt.execute_script("[global scope]", module.scope.global_ts.to_owned().into()) - { - error_code( - format!( - "Failed to execute global scope for module '{}':\n{}", + ) + .map_err(|err| WXRuntimeError { + code: 500, + message: format!("Failed to execute stdlib: {}", err), + })?; + rt.execute_script("[global scope]", module.scope.global_ts.to_owned().into()) + .map_err(|err| WXRuntimeError { + code: 500, + message: format!( + "Failed to initialize {}: {}", module.path.module_name(), err ), - 500, - self.mode.date_specifier(), - ); + })?; + Ok(rt) + } + + /// Reconcile the whole source tree atomically. Checking here as well as on + /// watcher events guarantees that the first request after a save sees it. + fn refresh_sources(&mut self) -> Result<(), WXRuntimeError> { + fn read_sources(root: &Path, files: &mut HashMap) -> std::io::Result<()> { + for entry in std::fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() { + read_sources(&path, files)?; + } else if matches!( + path.extension().and_then(|s| s.to_str()), + Some("wx" | "webx") + ) { + files.insert(path.canonicalize()?, std::fs::read_to_string(path)?); + } + } + Ok(()) } - info( - self.mode, - &format!("Initialized module '{}'", module.path.module_name()), - ); - rt + let mut sources = HashMap::new(); + read_sources(&self.info.source_root, &mut sources)?; + if self.last_sources.as_ref() == Some(&sources) { + return Ok(()); + } + // Remember invalid snapshots too: report once, then retry when bytes change. + self.last_sources = Some(sources.clone()); + let mut paths: Vec<_> = sources.keys().collect(); + paths.sort(); + let modules: Vec<_> = paths + .into_iter() + .map(|path| { + parse_webx_source(path, &sources[path]).map_err(|err| WXRuntimeError { + code: 500, + message: format!("Reload rejected: {:?}", err), + }) + }) + .collect::>()?; + let routes = WXRouteMap::from_modules(&modules)?; + let mut replacements = HashMap::new(); + for module in &modules { + let previous = self + .source_modules + .iter() + .find(|old| old.path == module.path); + if previous.is_none_or(|old| old.scope.global_ts != module.scope.global_ts) { + replacements.insert(module.path.clone(), self.new_module_js_runtime(module)?); + } + } + // Commit only after parsing, route validation, and JS initialization succeed. + self.modules + .retain(|path, _| modules.iter().any(|module| &module.path == path)); + self.modules.extend(replacements); + self.source_modules = modules; + self.routes = routes; + info(self.mode, "Reloaded source snapshot"); + Ok(()) } /// Tries to recompile all loaded modules at once and replace the runtime route map. @@ -702,31 +690,12 @@ impl WXRuntime { if let Ok(msg) = self.messages.recv_timeout(timeout_duration(self.mode)) { match msg { WXRuntimeMessage::Shutdown => break, - WXRuntimeMessage::New(module) => { - info( - self.mode, - &format!("New module: {}", module.path.module_name()), - ); - self.load_module(module); - self.recompile(); - } - WXRuntimeMessage::Swap(module) => { - info( - self.mode, - &format!("Reloaded module: {}", module.path.module_name()), - ); - // Module JS runtime is persistent between hot-swaps. - self.remove_module(&module.path); - self.load_module(module); - self.recompile(); - } - WXRuntimeMessage::Remove(path) => { - info( - self.mode, - &format!("Removed module: {}", path.module_name()), - ); - self.remove_module(&path); - self.recompile(); + WXRuntimeMessage::Reload => { + if self.mode.is_dev() { + if let Err(err) = self.refresh_sources() { + error_code(err.message, err.code, self.mode.date_specifier()); + } + } } WXRuntimeMessage::ExecuteRoute { request, @@ -746,8 +715,22 @@ impl WXRuntime { req: hyper::Request, addr: SocketAddr, ) -> Result>, WXRuntimeError> { - if let Some((_path, mut ctx, route)) = self.routes.resolve(req.method(), req.uri()) { + if self.mode.is_dev() { + if let Err(err) = self.refresh_sources() { + error_code(err.message, err.code, self.mode.date_specifier()); + } + } + if let Some((_path, bindings, route)) = self.routes.resolve(req.method(), req.uri()) { let module_runtime = self.modules.get_mut(&route.module_path).unwrap(); + // V8 handles must be allocated in the runtime that will consume them. + let mut ctx = WXRTContext::new(); + { + let scope = &mut module_runtime.handle_scope(); + for (name, value) in bindings { + let value: Local = v8::String::new(scope, &value).unwrap().into(); + ctx.bind(&name, Global::new(scope, value)); + } + } let route_result = route.execute(&mut ctx, module_runtime, &self.info, self.mode); let response = match route_result { Ok(response) => response, diff --git a/src/engine/test.rs b/src/engine/test.rs index c100c8d..9fb221b 100644 --- a/src/engine/test.rs +++ b/src/engine/test.rs @@ -76,10 +76,14 @@ struct TestResponse { impl TestServer { fn start(files: &[(&str, &str)]) -> Self { + Self::start_mode(files, WXMode::Dev(DebugLevel::Low)) + } + + fn start_mode(files: &[(&str, &str)], mode: WXMode) -> Self { let project = TestProject::new(files); let modules = files .iter() - .filter(|(name, _)| name.ends_with(".wx")) + .filter(|(name, _)| name.ends_with(".wx") || name.ends_with(".webx")) .map(|(name, _)| parse_webx_file(&project.0.join(name)).unwrap()) .collect(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -88,7 +92,6 @@ impl TestServer { let running = Arc::new(AtomicBool::new(true)); let (tx, rx) = mpsc::channel(); let info = WXRuntimeInfo::new(&project.0); - let mode = WXMode::Dev(DebugLevel::Low); let rt_running = running.clone(); let runtime_worker = std::thread::spawn(move || { let mut runtime = WXRuntime::new(rx, mode, info); @@ -213,3 +216,187 @@ async fn serves_static_files_and_handler_errors_over_http() { assert!(error.body.contains("failed to read file")); assert_eq!(server.request("GET", "/").await.status, StatusCode::OK); } + +#[tokio::test] +async fn route_specificity_is_independent_of_declaration_and_module_order() { + for reverse in [false, true] { + let routes = [ + "get /path/(anything: String)/here (

generic

)", + "get /path/to/here (

literal

)", + "get /path/*/here (

wildcard

)", + "location /nested { get /(id: String) (

nested generic

) get /fixed (

nested literal

) }", + "get /other/* (

fallback

)", + "delete /path/to/here (

deleted

)", + ]; + let source = if reverse { + routes.into_iter().rev().collect::>() + } else { + routes.to_vec() + } + .join("\n"); + let files = if reverse { + vec![ + ("specific.wx", "get /other/fixed (

other literal

)\n"), + ("generic.wx", source.as_str()), + ] + } else { + vec![ + ("generic.wx", source.as_str()), + ("specific.wx", "get /other/fixed (

other literal

)\n"), + ] + }; + let server = TestServer::start(&files); + for _ in 0..4 { + for (path, expected) in [ + ("/path/to/here", "

literal

"), + ("/path/else/here", "

generic

"), + ("/nested/fixed", "

nested literal

"), + ("/nested/123", "

nested generic

"), + ("/other/fixed", "

other literal

"), + ("/other/anything", "

fallback

"), + ] { + let response = server.request("GET", path).await; + assert_eq!(response.status, StatusCode::OK); + assert_eq!(response.body, expected, "{path}"); + } + } + assert_eq!( + server.request("GET", "/path/to/here/extra").await.status, + StatusCode::NOT_FOUND + ); + assert_eq!( + server.request("GET", "/nested").await.status, + StatusCode::NOT_FOUND + ); + assert_eq!( + server.request("DELETE", "/path/to/here").await.body, + "

deleted

" + ); + } +} + +#[test] +fn duplicate_and_ambiguous_routes_report_both_locations() { + use crate::analysis::routes::{ + ambiguous_route_warnings, analyze_duplicate_routes, analyze_invalid_routes, + extract_flat_routes, + }; + for (a, b) in [ + ("get /same (

a

)", "get /same (

b

)"), + ( + "get /item/(a: String) (

a

)", + "get /item/(b: Int) (

b

)", + ), + ( + "get /nested/same (

a

)", + "location /nested { get /same (

b

) }", + ), + ] { + let project = TestProject::new(&[("a.wx", a), ("b.wx", b)]); + let modules = ["a.wx", "b.wx"].map(|name| parse_webx_file(&project.0.join(name)).unwrap()); + let err = analyze_duplicate_routes(&modules).unwrap_err(); + assert!(err.message.contains("Duplicate/ambiguous")); + assert!(err.message.contains("a line"), "{}", err.message); + assert!(err.message.contains("b line"), "{}", err.message); + } + let project = TestProject::new(&[( + "cross.wx", + "get /fixed/(x: String) (a)\nget /(y: String)/fixed (b)\npost /empty (ok)\n", + )]); + let modules = [parse_webx_file(&project.0.join("cross.wx")).unwrap()]; + assert_eq!( + ambiguous_route_warnings(&extract_flat_routes(&modules)).len(), + 1 + ); + analyze_invalid_routes(&modules).unwrap(); +} + +#[tokio::test] +async fn edited_html_is_visible_on_the_very_next_request() { + let server = TestServer::start(&[("index.wx", "get / (

before

)\n")]); + assert_eq!(server.request("GET", "/").await.body, "

before

"); + // No sleep or watcher polling: the completed save precedes the request. + for text in ["after", "again", "final", "Hej 🌍 — café"] { + server + .project + .write("index.wx", &format!("get / (

{text}

)\n")); + assert_eq!( + server.request("GET", "/").await.body, + format!("

{text}

") + ); + } + server + .project + .write("save.tmp", "get / (

atomic save

)\n"); + fs::rename( + server.project.0.join("save.tmp"), + server.project.0.join("index.wx"), + ) + .unwrap(); + assert_eq!( + server.request("GET", "/").await.body, + "

atomic save

" + ); +} + +#[tokio::test] +async fn reload_adds_removes_and_renames_modules_and_keeps_last_good_snapshot() { + let source = "global { function count() { return 7; } }\nget / -> count()\n"; + let server = TestServer::start(&[("index.wx", source)]); + assert_eq!(server.request("GET", "/").await.body, "7"); + for bad in [ + "get / (unfinished", + "get / (a)\nget / (b)\n", + "global { this is invalid JS; }\nget / (broken)\n", + ] { + server.project.write("index.wx", bad); + assert_eq!(server.request("GET", "/").await.body, "7", "{bad}"); + } + server.project.write("index.wx", source); + server + .project + .write("nested/added.webx", "get /added (added)\n"); + assert_eq!(server.request("GET", "/added").await.body, "added"); + fs::rename( + server.project.0.join("nested/added.webx"), + server.project.0.join("renamed.wx"), + ) + .unwrap(); + assert_eq!(server.request("GET", "/added").await.body, "added"); + fs::remove_file(server.project.0.join("renamed.wx")).unwrap(); + assert_eq!( + server.request("GET", "/added").await.status, + StatusCode::NOT_FOUND + ); + assert_eq!(server.request("GET", "/").await.body, "7"); +} + +#[tokio::test] +async fn html_only_reload_preserves_globals_and_changed_globals_reset_them() { + let source = "global { let n = 0; function count() { return ++n; } }\nget / -> count()\nget /page (before)\n"; + let server = TestServer::start(&[("index.wx", source)]); + assert_eq!(server.request("GET", "/").await.body, "1"); + server + .project + .write("index.wx", &source.replace("before", "after")); + assert_eq!(server.request("GET", "/page").await.body, "after"); + assert_eq!(server.request("GET", "/").await.body, "2"); + server + .project + .write("index.wx", &source.replace("n = 0", "n = 100")); + assert_eq!(server.request("GET", "/").await.body, "101"); +} + +#[tokio::test] +async fn production_does_not_reload_modules_but_static_files_are_read_each_time() { + let source = "get / (before)\nget /file -> static(\"page.html\")\n"; + let server = TestServer::start_mode( + &[("index.wx", source), ("page.html", "before")], + WXMode::Prod, + ); + assert_eq!(server.request("GET", "/").await.body, "before"); + server.project.write("index.wx", "get / (after)\n"); + server.project.write("page.html", "after"); + assert_eq!(server.request("GET", "/").await.body, "before"); + assert_eq!(server.request("GET", "/file").await.body, "after"); +} diff --git a/src/file/parser.rs b/src/file/parser.rs index 4aad5b8..e5c735e 100644 --- a/src/file/parser.rs +++ b/src/file/parser.rs @@ -1,9 +1,5 @@ use crate::file::webx::WXModule; -use std::{ - fmt::Display, - io::{BufReader, Read}, - path::PathBuf, -}; +use std::{fmt::Display, path::PathBuf}; use super::webx::{ WXBody, WXBodyType, WXHandler, WXInfoField, WXModel, WXModulePath, WXRoute, WXRouteHandlerCall, @@ -117,8 +113,8 @@ impl WebXParserError { struct WebXFileParser<'a> { file: &'a PathBuf, - _content: &'a String, - reader: BufReader<&'a [u8]>, + _content: &'a str, + reader: std::str::Chars<'a>, line: usize, column: usize, peeked_index: u64, // "next index" @@ -127,11 +123,11 @@ struct WebXFileParser<'a> { } impl<'a> WebXFileParser<'a> { - fn new(file: &'a PathBuf, content: &'a String) -> WebXFileParser<'a> { + fn new(file: &'a PathBuf, content: &'a str) -> WebXFileParser<'a> { let mut p = WebXFileParser { file, _content: content, - reader: BufReader::new(content.as_bytes()), + reader: content.chars(), line: 1, column: 1, peeked_index: 0, @@ -157,15 +153,9 @@ impl<'a> WebXFileParser<'a> { /// # Errors /// If the file cannot be read, an error is returned and the program exits. fn __raw_next(&mut self) -> Result, WebXParserError> { - let mut buf = [0; 1]; - let bytes_read = self - .reader - .read(&mut buf) - .map_err(|err| WebXParserError::IoError(err, self.file.clone()))?; - if bytes_read == 0 { + let Some(c) = self.reader.next() else { return Ok(None); - } - let c = buf[0] as char; + }; self.peeked_index += 1; // Index of the character returned by the next call to `next`. self.next_index = self.peeked_index - 1; @@ -355,7 +345,13 @@ impl<'a> WebXFileParser<'a> { loop { let nc = self.next()?; if nc.is_none() { - break; + return Err(WebXParserError::unexpected( + "end of file", + format!("parsing a block (expected '{}')", end), + self.line, + self.column, + self.file.clone(), + )); } let nc = nc.unwrap(); if nc == start { @@ -498,7 +494,7 @@ impl<'a> WebXFileParser<'a> { } // No comma means end of type pairs. self.next()?; // Consume the comma. self.skip_whitespace(true); - if allow_stray_comma && !char::is_alphabetic(self.peek().unwrap()) { + if allow_stray_comma && !self.peek().is_some_and(char::is_alphabetic) { break; } // Allow stray comma. } @@ -519,7 +515,7 @@ impl<'a> WebXFileParser<'a> { if let Some((_, end)) = GROUPING.iter().find(|(start, _)| *start == nc) { s.push(nc); s.push_str(&self.parse_ts_expression(*end)?); - s.push(self.next()?.unwrap()); // Consume the end character. + s.push(self.expect("parsing a grouped expression")?); // Consume the end character. } else { s.push(nc); } @@ -952,6 +948,10 @@ impl<'a> WebXFileParser<'a> { pub fn parse_webx_file(file: &PathBuf) -> Result { let file_contents = std::fs::read_to_string(file).map_err(|err| WebXParserError::IoError(err, file.clone()))?; - let mut parser = WebXFileParser::new(file, &file_contents); - parser.parse_module() + parse_webx_source(file, &file_contents) +} + +/// Parse a stable source snapshot, so a reload uses exactly the bytes it checked. +pub fn parse_webx_source(file: &PathBuf, source: &str) -> Result { + WebXFileParser::new(file, source).parse_module() } diff --git a/src/runner.rs b/src/runner.rs index 8e0cff7..5a1e2d8 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -216,13 +216,14 @@ pub fn run(root: &Path, mode: WXMode, running: Arc) { analyze_module_routes(&webx_modules); print_start_info(&webx_modules, mode, &config, time_start.elapsed()); + let mut info = WXRuntimeInfo::new(root); + info.source_root = source_root.clone(); let (rt_tx, rt_rx) = std::sync::mpsc::channel(); if mode.is_dev() { let fw_rt_tx = rt_tx.clone(); let fw_running = running.clone(); let fw_hnd = std::thread::spawn(move || WXFileWatcher::run(mode, source_root, fw_rt_tx, fw_running)); - let info = WXRuntimeInfo::new(root); let runtime_running = running.clone(); let runtime_hnd = std::thread::spawn(move || { let mut runtime = WXRuntime::new(rt_rx, mode, info); @@ -240,7 +241,6 @@ pub fn run(root: &Path, mode: WXMode, running: Arc) { } } else { // If we are in production mode, run the `server` in main thread. - let info = WXRuntimeInfo::new(root); let runtime_running = running.clone(); let runtime_hnd = std::thread::spawn(move || { let mut runtime = WXRuntime::new(rt_rx, mode, info);