diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 44c41cc..11e5d9c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -279,6 +279,17 @@ enum Commands { /// `@angular/build:dev-server`. #[arg(long = "ssl-cert")] ssl_cert: Option, + /// Enable Hot Module Replacement: edits to component templates and + /// styles (and global stylesheets) are applied in place without a + /// full page reload, preserving component and form state. Overrides + /// `architect.serve.options.hmr` in `angular.json`. Mirrors the `hmr` + /// option of `@angular/build:dev-server`. + #[arg(long, conflicts_with = "no_hmr")] + hmr: bool, + /// Disable Hot Module Replacement, forcing a full page reload on every + /// rebuild. Overrides `architect.serve.options.hmr` in `angular.json`. + #[arg(long = "no-hmr", conflicts_with = "hmr")] + no_hmr: bool, }, /// Extract translatable messages from every component template in the /// project and emit a translation file (XLIFF 2.0 by default; XLIFF 1.2 @@ -410,6 +421,8 @@ fn main() { ssl, ssl_key, ssl_cert, + hmr, + no_hmr, } => { let parsed_headers = match parse_header_overrides(headers.as_deref()) { Ok(h) => h, @@ -418,6 +431,15 @@ fn main() { process::exit(1); } }; + // CLI flags win over angular.json: `--hmr` → Some(true), + // `--no-hmr` → Some(false), neither → None (inherit config). + let hmr_override = if hmr { + Some(true) + } else if no_hmr { + Some(false) + } else { + None + }; if let Err(e) = serve_cmd::run( &project, Some(&configuration), @@ -430,6 +452,7 @@ fn main() { ssl, ssl_key.as_deref(), ssl_cert.as_deref(), + hmr_override, ) { eprintln!("{} {e}", "Error:".red().bold()); process::exit(1); @@ -1883,7 +1906,7 @@ pub(crate) fn resolve_out_dir( } /// Try to find angular.json by searching upward from the project file's directory. -fn find_and_resolve_angular_json( +pub(crate) fn find_and_resolve_angular_json( project: &Path, configuration: Option<&str>, ) -> NgcResult> { diff --git a/crates/cli/src/serve_cmd.rs b/crates/cli/src/serve_cmd.rs index f8465d7..a990ba4 100644 --- a/crates/cli/src/serve_cmd.rs +++ b/crates/cli/src/serve_cmd.rs @@ -17,8 +17,13 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::channel; use std::sync::Arc; +use std::collections::HashMap; +use std::sync::Mutex; + use colored::Colorize; -use ngc_dev_server::{DevServer, DevServerConfig, DevServerEvent, TlsConfig}; +use ngc_dev_server::{ + ComponentUpdates, DevServer, DevServerConfig, DevServerEvent, TlsConfig, HMR_RUNTIME_PRELUDE, +}; use ngc_diagnostics::{NgcError, NgcResult}; use ngc_watch::{Watcher, WatcherConfig}; @@ -40,6 +45,7 @@ pub fn run( ssl: bool, ssl_key: Option<&Path>, ssl_cert: Option<&Path>, + hmr_override: Option, ) -> NgcResult<()> { run_with_stop( project, @@ -53,6 +59,7 @@ pub fn run( ssl, ssl_key, ssl_cert, + hmr_override, install_ctrlc, ) } @@ -121,6 +128,7 @@ pub(crate) fn run_with_stop( ssl: bool, ssl_key: Option<&Path>, ssl_cert: Option<&Path>, + hmr_override: Option, install_stop: impl FnOnce(Arc), ) -> NgcResult<()> { let tls = resolve_tls(ssl, ssl_key, ssl_cert, host)?; @@ -128,6 +136,25 @@ pub(crate) fn run_with_stop( let out_dir = crate::resolve_out_dir(project, None, configuration)?; let mut cache = BuildCache::new(); + // Resolve HMR: CLI `--hmr`/`--no-hmr` wins; otherwise inherit + // `architect.serve.options.hmr` from angular.json (default `false`). + // Also collect the absolute paths of the global stylesheet entries so a + // rebuild that touches only those can be classified as a CSS-only update. + let resolved = crate::find_and_resolve_angular_json(project, configuration)?; + let hmr_enabled = hmr_override.unwrap_or_else(|| resolved.as_ref().map(|p| p.hmr).unwrap_or(false)); + let global_style_paths: std::collections::HashSet = resolved + .as_ref() + .map(|p| { + p.styles + .iter() + .map(|s| canonical_or_owned(&s.path)) + .collect() + }) + .unwrap_or_default(); + if hmr_enabled { + eprintln!("{}", "ngc-rs HMR enabled".bold().green()); + } + // Normalize the user-supplied servePath up-front so the dev server // mount and the index.html `` fallback agree on the // canonical `/foo/` form (see `ngc_dev_server::normalize_serve_path`). @@ -152,6 +179,17 @@ pub(crate) fn run_with_stop( initial.output_files.len(), ); + // Shared registry of per-component HMR update modules served at + // `/@ng/component`. Empty until the compiler emits update modules; we + // share one handle between the dev server and the rebuild callback. + let component_updates: ComponentUpdates = Arc::new(Mutex::new(HashMap::new())); + + // When HMR is on, bind `import.meta.hot` inside the entry module so the + // per-component initializers can register update handlers. + if hmr_enabled { + inject_hmr_runtime(&out_dir); + } + let (event_tx, event_rx) = channel::(); let cfg = DevServerConfig::new(&out_dir) .with_host(host.to_string()) @@ -159,7 +197,8 @@ pub(crate) fn run_with_stop( .with_serve_path(normalized_serve_path.as_deref()) .with_allowed_hosts(allowed_hosts.iter().cloned()) .with_headers(headers.iter().cloned()) - .with_tls(tls); + .with_tls(tls) + .with_component_updates(Arc::clone(&component_updates)); let server = DevServer::start(cfg, event_rx)?; let scheme = server.scheme(); let url = match server.serve_path() { @@ -185,6 +224,10 @@ pub(crate) fn run_with_stop( let project_path = project.to_path_buf(); let configuration_owned = configuration.map(|s| s.to_string()); let serve_path_owned = normalized_serve_path.clone(); + let out_dir_owned = out_dir.clone(); + // Monotonic cache-buster for the swapped `styles.css` href on CSS-only + // updates; must change every rebuild so the browser re-fetches. + let mut hmr_tick: u64 = 0; let build_fn = move |dirty: &[PathBuf]| -> NgcResult<()> { if dirty.iter().any(|p| !is_ts_path(p)) { @@ -209,7 +252,23 @@ pub(crate) fn run_with_stop( result.modules_bundled, dirty.len() ); - if event_tx.send(DevServerEvent::Reload).is_err() { + // Re-bind `import.meta.hot` in the freshly written entry chunk. + if hmr_enabled { + inject_hmr_runtime(&out_dir_owned); + } + // CSS-only fast path: when HMR is on and every changed file is + // a global stylesheet entry, swap `styles.css` in place + // instead of reloading (preserving component/form state). + let css_only = hmr_enabled && is_global_css_only_change(dirty, &global_style_paths); + let event = if css_only { + hmr_tick += 1; + DevServerEvent::CssUpdate { + timestamp: hmr_tick, + } + } else { + DevServerEvent::Reload + }; + if event_tx.send(event).is_err() { tracing::debug!("dev server event channel closed"); } Ok(()) @@ -249,6 +308,55 @@ pub(crate) fn build_failure_event(err: &NgcError) -> DevServerEvent { } } +/// Canonicalize `path`, falling back to its owned form when canonicalization +/// fails (e.g. the file was deleted between resolve and compare). Used so the +/// watcher's emitted paths and the resolved style paths compare equal even +/// across symlinks. +fn canonical_or_owned(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +/// Prepend the HMR runtime prelude to the entry chunk (`main.js`) so the +/// per-component HMR initializers can resolve `import.meta.hot`. The build +/// rewrites `main.js` from scratch each cycle, so this runs after every +/// successful build. A guard skips the work if the prelude is already present +/// (defensive — a fresh build never has it). Failures are logged and ignored: +/// a missing entry chunk just means HMR initializers won't bind, which +/// degrades to live reload rather than breaking the served app. +fn inject_hmr_runtime(out_dir: &Path) { + let main_js = out_dir.join("main.js"); + let existing = match std::fs::read_to_string(&main_js) { + Ok(s) => s, + Err(e) => { + tracing::debug!(path = %main_js.display(), error = %e, "no entry chunk to inject HMR runtime into"); + return; + } + }; + if existing.starts_with(HMR_RUNTIME_PRELUDE) { + return; + } + let mut patched = String::with_capacity(HMR_RUNTIME_PRELUDE.len() + existing.len()); + patched.push_str(HMR_RUNTIME_PRELUDE); + patched.push_str(&existing); + if let Err(e) = std::fs::write(&main_js, patched) { + tracing::debug!(path = %main_js.display(), error = %e, "could not inject HMR runtime"); + } +} + +/// True when a rebuild's `dirty` set is non-empty and every changed file is a +/// global stylesheet entry — the case where HMR can swap `styles.css` in place +/// instead of reloading. An empty set (or any non-stylesheet change) returns +/// `false`, falling back to a full reload. +fn is_global_css_only_change( + dirty: &[PathBuf], + global_style_paths: &std::collections::HashSet, +) -> bool { + !dirty.is_empty() + && dirty + .iter() + .all(|p| global_style_paths.contains(&canonical_or_owned(p))) +} + fn error_location(err: &NgcError) -> (Option, Option, Option) { match err { NgcError::ParseError { @@ -449,6 +557,48 @@ mod tests { assert!(matches!(err, NgcError::Io { .. })); } + #[test] + fn css_only_change_classification() { + use std::collections::HashSet; + let styles: HashSet = [PathBuf::from("/proj/src/styles.css"), PathBuf::from("/proj/src/theme.scss")] + .into_iter() + .collect(); + + // All dirty files are global stylesheets → CSS-only. + assert!(is_global_css_only_change( + &[PathBuf::from("/proj/src/styles.css")], + &styles + )); + assert!(is_global_css_only_change( + &[ + PathBuf::from("/proj/src/styles.css"), + PathBuf::from("/proj/src/theme.scss") + ], + &styles + )); + + // A non-stylesheet change (or a stylesheet not in the global set) + // forces a full reload. + assert!(!is_global_css_only_change( + &[PathBuf::from("/proj/src/app.component.ts")], + &styles + )); + assert!(!is_global_css_only_change( + &[ + PathBuf::from("/proj/src/styles.css"), + PathBuf::from("/proj/src/app.component.ts") + ], + &styles + )); + assert!(!is_global_css_only_change( + &[PathBuf::from("/proj/src/app.component.css")], + &styles + )); + + // Empty dirty set never qualifies. + assert!(!is_global_css_only_change(&[], &styles)); + } + #[test] fn build_failure_event_omits_path_for_pathless_errors() { let err = NgcError::ServeError { diff --git a/crates/dev-server/src/lib.rs b/crates/dev-server/src/lib.rs index 890af2d..197f321 100644 --- a/crates/dev-server/src/lib.rs +++ b/crates/dev-server/src/lib.rs @@ -27,6 +27,7 @@ //! mounts a full-page error overlay (dismissible with `Esc`) showing the //! build error and source location. +use std::collections::HashMap; use std::io::Write; use std::net::{SocketAddr, TcpListener, ToSocketAddrs}; use std::path::{Path, PathBuf}; @@ -46,10 +47,32 @@ use tiny_http::{Header, Method, Response, Server, SslConfig, StatusCode}; /// /// * [`DevServerEvent::Reload`] → `event: reload` /// * [`DevServerEvent::BuildFailed`] → `event: build-failed` +/// * [`DevServerEvent::CssUpdate`] → `event: css-update` #[derive(Debug, Clone)] pub enum DevServerEvent { /// A successful rebuild — connected browsers should refresh the page. Reload, + /// A successful rebuild that only changed global stylesheet(s). HMR + /// clients swap the `styles.css` `` in place (cache-busting with + /// `timestamp`) without reloading the page, preserving component and + /// form state. Only emitted when HMR is enabled; otherwise a plain + /// [`DevServerEvent::Reload`] is sent. + CssUpdate { + /// Monotonic cache-buster appended to the swapped stylesheet href. + timestamp: u64, + }, + /// A successful rebuild that changed only a single component's template + /// and/or styles. HMR clients re-fetch that component's update module + /// from `/@ng/component?c=&t=` and call + /// `ɵɵreplaceMetadata` to swap it in place — no reload, state preserved. + /// `id` is the percent-encoded `relpath@ClassName` the compiler embeds in + /// the component's HMR initializer. Only emitted when HMR is enabled. + ComponentUpdate { + /// Percent-encoded component id (`encodeURIComponent("relpath@Class")`). + id: String, + /// Monotonic cache-buster matching the `t` query param on the fetch. + timestamp: u64, + }, /// A rebuild failed — connected browsers should display an error /// overlay with the message and (when available) the offending file /// and source coordinates. @@ -78,6 +101,14 @@ impl From for DevServerEvent { } } +/// Shared registry of per-component HMR update modules, keyed by the +/// percent-encoded component id (`encodeURIComponent("relpath@Class")`). +/// The build pipeline replaces its contents after each rebuild; the +/// `/@ng/component?c=` endpoint reads it to serve the update module a +/// running app dynamically imports. Cloning shares the same underlying map +/// (`Arc`), so the serve loop and the build callback see each other's writes. +pub type ComponentUpdates = Arc>>; + /// Configuration for [`DevServer`]. #[derive(Debug, Clone)] pub struct DevServerConfig { @@ -107,6 +138,10 @@ pub struct DevServerConfig { /// the long-lived SSE live-reload stream — is wrapped in TLS. Mirrors /// `@angular/build:dev-server`'s `ssl`/`sslKey`/`sslCert` options. pub tls: Option, + /// Registry of per-component HMR update modules served at + /// `/@ng/component?c=`. Empty by default (live reload only); the + /// `serve` command shares a handle and populates it on each HMR rebuild. + pub component_updates: ComponentUpdates, } impl DevServerConfig { @@ -121,9 +156,18 @@ impl DevServerConfig { allowed_hosts: Vec::new(), headers: Vec::new(), tls: None, + component_updates: Arc::new(Mutex::new(HashMap::new())), } } + /// Share an external [`ComponentUpdates`] registry so the build pipeline + /// can publish per-component HMR update modules the `/@ng/component` + /// endpoint then serves. Pass a handle you retain a clone of. + pub fn with_component_updates(mut self, updates: ComponentUpdates) -> Self { + self.component_updates = updates; + self + } + /// Override the bind host. pub fn with_host(mut self, host: impl Into) -> Self { self.host = host.into(); @@ -561,6 +605,7 @@ impl DevServer { let allowed_hosts = Arc::new(AllowedHosts::resolve(&config.allowed_hosts, &config.host)); let allowed_hosts_for_loop = Arc::clone(&allowed_hosts); let custom_headers = Arc::new(CustomHeaders::resolve(&config.headers)); + let component_updates = Arc::clone(&config.component_updates); let join = thread::Builder::new() .name("ngc-dev-server-accept".into()) .spawn(move || { @@ -571,6 +616,7 @@ impl DevServer { serve_path_for_loop, allowed_hosts_for_loop, custom_headers, + component_updates, ) }) .map_err(|e| NgcError::ServeError { @@ -695,6 +741,15 @@ fn fanout_loop(rx: Receiver, clients: SseClients) { pub fn sse_frame(event: &DevServerEvent) -> String { match event { DevServerEvent::Reload => "event: reload\ndata: rebuild\n\n".to_string(), + DevServerEvent::CssUpdate { timestamp } => { + format!("event: css-update\ndata: {{\"timestamp\":{timestamp}}}\n\n") + } + DevServerEvent::ComponentUpdate { id, timestamp } => { + // `id` is already percent-encoded JS-identifier-safe text, but + // route it through serde so any stray quote can't break the JSON. + let payload = serde_json::json!({ "id": id, "timestamp": timestamp }); + format!("event: angular:component-update\ndata: {payload}\n\n") + } DevServerEvent::BuildFailed { message, file, @@ -712,6 +767,7 @@ pub fn sse_frame(event: &DevServerEvent) -> String { } } +#[allow(clippy::too_many_arguments)] fn serve_loop( server: Arc, root: PathBuf, @@ -719,6 +775,7 @@ fn serve_loop( serve_path: Option, allowed_hosts: Arc, headers: Arc, + component_updates: ComponentUpdates, ) { for request in server.incoming_requests() { let root = root.clone(); @@ -726,6 +783,7 @@ fn serve_loop( let serve_path = serve_path.clone(); let allowed_hosts = Arc::clone(&allowed_hosts); let headers = Arc::clone(&headers); + let component_updates = Arc::clone(&component_updates); thread::spawn(move || { if let Err(e) = handle_request( request, @@ -734,6 +792,7 @@ fn serve_loop( serve_path.as_deref(), &allowed_hosts, &headers, + &component_updates, ) { tracing::warn!(error = %e, "dev server request failed"); } @@ -741,6 +800,7 @@ fn serve_loop( } } +#[allow(clippy::too_many_arguments)] fn handle_request( request: tiny_http::Request, root: &Path, @@ -748,6 +808,7 @@ fn handle_request( serve_path: Option<&str>, allowed_hosts: &AllowedHosts, headers: &CustomHeaders, + component_updates: &ComponentUpdates, ) -> NgcResult<()> { if !matches!(request.method(), Method::Get | Method::Head) { let resp = Response::from_string("method not allowed").with_status_code(StatusCode(405)); @@ -774,9 +835,56 @@ fn handle_request( return handle_sse(request, clients, headers); } + if stripped == "/@ng/component" { + return handle_component_update(request, &url, component_updates, headers); + } + serve_static(request, root, stripped, serve_path, headers) } +/// Serve a per-component HMR update module for `GET /@ng/component?c=`. +/// +/// The `c` query value is the percent-encoded component id the compiler +/// embedded in the component's HMR initializer; it's used verbatim as the +/// registry key (the running app sends exactly what was embedded). When no +/// module is registered for the id, an empty `200` is returned — the running +/// app's loader guards on `m.default`, so an empty module is a safe no-op +/// (mirrors `@angular/build`'s component middleware). +fn handle_component_update( + request: tiny_http::Request, + url: &str, + component_updates: &ComponentUpdates, + headers: &CustomHeaders, +) -> NgcResult<()> { + let Some(id) = query_param(url, "c") else { + let resp = Response::from_string("missing c parameter").with_status_code(StatusCode(400)); + return request.respond(resp).map_err(io_err); + }; + let code = component_updates + .lock() + .ok() + .and_then(|map| map.get(id).cloned()) + .unwrap_or_default(); + let mut resp = Response::from_data(code.into_bytes()); + resp.add_header(header("Content-Type", "text/javascript")?); + resp.add_header(header("Cache-Control", "no-cache")?); + headers.apply(&mut resp, &["Content-Type", "Cache-Control"]); + request.respond(resp).map_err(io_err) +} + +/// Extract a raw (still percent-encoded) query parameter value from a URL. +/// +/// Returns the substring after `=` up to the next `&`. The value is +/// **not** percent-decoded: component ids are stored and matched in their +/// encoded form, so decoding here would break the registry lookup. +fn query_param<'a>(url: &'a str, name: &str) -> Option<&'a str> { + let query = url.split_once('?')?.1; + query.split('&').find_map(|pair| { + let (k, v) = pair.split_once('=')?; + (k == name).then_some(v) + }) +} + /// Read the request's `Host:` header value, or return the empty string when /// the client didn't send one. HTTP/1.1 requires the header, but a misbehaving /// client (or a port scanner sending an HTTP/1.0 request) could omit it — in @@ -1074,7 +1182,18 @@ pub fn mime_for(path: &Path) -> &'static str { /// Malformed `data:` payloads (non-JSON, missing keys) are tolerated and /// fall back to a generic "build failed" message rather than crashing the /// listener. -pub const LIVE_RELOAD_SCRIPT: &str = r#""#; +pub const LIVE_RELOAD_SCRIPT: &str = r#""#; + +/// Module-scope prelude prepended to the entry chunk (`main.js`) when HMR is +/// enabled. The per-component HMR initializers the compiler emits reference +/// `import.meta.hot`, which only exists inside a module's `import.meta`; this +/// binds it to the event bus the injected [`LIVE_RELOAD_SCRIPT`] publishes on +/// `window.__ngcHmr`. The inline script runs before the deferred module, so +/// `window.__ngcHmr` is already defined when this line executes. A no-op stub +/// is used as a fallback so the bundle never throws if live reload failed to +/// initialise. +pub const HMR_RUNTIME_PRELUDE: &str = + "import.meta.hot=globalThis.__ngcHmr||{on:function(){},off:function(){},send:function(){}};\n"; /// Insert the live-reload client script into an HTML byte buffer. /// @@ -1246,6 +1365,74 @@ mod tests { ); } + #[test] + fn sse_frame_for_css_update_emits_named_event_with_timestamp() { + let frame = sse_frame(&DevServerEvent::CssUpdate { timestamp: 7 }); + assert!(frame.starts_with("event: css-update\n")); + let data_line = frame.lines().nth(1).expect("data line"); + let json: serde_json::Value = serde_json::from_str( + data_line.strip_prefix("data: ").expect("data: prefix"), + ) + .expect("css-update payload is JSON"); + assert_eq!(json["timestamp"], 7); + assert!(frame.ends_with("\n\n")); + } + + #[test] + fn live_reload_script_handles_css_update_in_place() { + // The injected client must subscribe to `css-update` and swap the + // global styles.css link instead of reloading the page. + assert!(LIVE_RELOAD_SCRIPT.contains("addEventListener('css-update'")); + assert!(LIVE_RELOAD_SCRIPT.contains("function swapCss")); + assert!(LIVE_RELOAD_SCRIPT.contains("styles\\.css")); + // CSS updates must not trigger a full reload. + let after_css = LIVE_RELOAD_SCRIPT + .split("addEventListener('css-update'") + .nth(1) + .expect("css-update handler present"); + let handler_body = after_css.split("});").next().unwrap_or(""); + assert!( + !handler_body.contains("location.reload"), + "css-update handler must not reload the page" + ); + } + + #[test] + fn sse_frame_for_component_update_emits_angular_event() { + let frame = sse_frame(&DevServerEvent::ComponentUpdate { + id: "src%2Fapp%2Fapp.component.ts%40AppComponent".to_string(), + timestamp: 42, + }); + assert!(frame.starts_with("event: angular:component-update\n")); + let data_line = frame.lines().nth(1).expect("data line"); + let json: serde_json::Value = + serde_json::from_str(data_line.strip_prefix("data: ").expect("data: prefix")) + .expect("component-update payload is JSON"); + assert_eq!(json["id"], "src%2Fapp%2Fapp.component.ts%40AppComponent"); + assert_eq!(json["timestamp"], 42); + assert!(frame.ends_with("\n\n")); + } + + #[test] + fn query_param_extracts_raw_encoded_value() { + let url = "/@ng/component?c=src%2Fapp%40App&t=17"; + assert_eq!(query_param(url, "c"), Some("src%2Fapp%40App")); + assert_eq!(query_param(url, "t"), Some("17")); + assert_eq!(query_param(url, "missing"), None); + assert_eq!(query_param("/@ng/component", "c"), None); + } + + #[test] + fn live_reload_script_exposes_hmr_bus() { + // The injected client must publish the `__ngcHmr` bus and dispatch + // component-update events to registered handlers. + assert!(LIVE_RELOAD_SCRIPT.contains("window.__ngcHmr")); + assert!(LIVE_RELOAD_SCRIPT.contains("addEventListener('angular:component-update'")); + // The runtime prelude binds import.meta.hot to that bus. + assert!(HMR_RUNTIME_PRELUDE.contains("import.meta.hot")); + assert!(HMR_RUNTIME_PRELUDE.contains("globalThis.__ngcHmr")); + } + #[test] fn sse_frame_for_build_failed_emits_named_event_with_json_payload() { let event = DevServerEvent::BuildFailed { diff --git a/crates/dev-server/tests/integration.rs b/crates/dev-server/tests/integration.rs index fa3ac40..08de0c6 100644 --- a/crates/dev-server/tests/integration.rs +++ b/crates/dev-server/tests/integration.rs @@ -118,6 +118,45 @@ fn get_root_returns_index_html_with_injected_client() { assert!(body.contains("

hi

")); } +#[test] +fn component_endpoint_serves_registered_update_module() { + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + let root = TempDir::new().expect("tempdir"); + write_file(root.path(), "index.html", b""); + let registry: ngc_dev_server::ComponentUpdates = Arc::new(Mutex::new(HashMap::new())); + let id = "src%2Fapp%2Fapp.component.ts%40AppComponent"; + registry + .lock() + .unwrap() + .insert(id.to_string(), "export default function(){}".to_string()); + + let cfg = DevServerConfig::new(root.path()) + .with_port(0) + .with_component_updates(Arc::clone(®istry)); + let (_tx, rx) = channel::(); + let server = DevServer::start(cfg, rx).expect("start dev server"); + + // Registered id → the update module, as text/javascript. + let resp = http_get(server.addr(), &format!("/@ng/component?c={id}&t=99")); + assert_eq!(resp.status, 200); + assert!(resp + .header("Content-Type") + .expect("content-type") + .starts_with("text/javascript")); + assert_eq!(resp.body, b"export default function(){}"); + + // Unknown id → empty 200 (the running app guards on m.default). + let resp = http_get(server.addr(), "/@ng/component?c=nope&t=1"); + assert_eq!(resp.status, 200); + assert!(resp.body.is_empty()); + + // Missing `c` → 400. + let resp = http_get(server.addr(), "/@ng/component"); + assert_eq!(resp.status, 400); +} + #[test] fn get_index_html_directly_also_injects_client() { let fx = Fixture::new(); diff --git a/crates/project-resolver/src/angular_json.rs b/crates/project-resolver/src/angular_json.rs index 3f84d8b..3549abb 100644 --- a/crates/project-resolver/src/angular_json.rs +++ b/crates/project-resolver/src/angular_json.rs @@ -93,6 +93,32 @@ pub enum RawLocaleEntry { pub struct RawArchitect { /// Build target configuration. pub build: Option, + /// Serve (dev-server) target configuration. Only the options ngc-rs + /// honours are modelled — currently just `hmr`. + pub serve: Option, +} + +/// A serve target (`@angular/build:dev-server`) with default options and +/// named configurations. Only the subset ngc-rs reads is modelled. +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RawServeTarget { + /// Default serve options. + pub options: Option, + /// Named configurations (e.g. "production", "development"). + pub configurations: Option>, + /// Default configuration name used when none is specified. + pub default_configuration: Option, +} + +/// Serve options from `architect.serve.options` (or a per-configuration +/// block). Only `hmr` is honoured today. +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RawServeOptions { + /// Enable Hot Module Replacement. When absent, ngc-rs defaults to `false` + /// (full-reload live reload). + pub hmr: Option, } /// A build target with default options and named configurations. @@ -532,6 +558,11 @@ pub struct ResolvedAngularProject { /// is by exact name or `/...` prefix, mirroring how /// `@angular/build:application` (esbuild) treats package externals. pub external_dependencies: Vec, + /// Resolved `architect.serve.options.hmr` (with the active + /// configuration's override layered on top). `false` when absent — + /// matching ngc-rs's default of full-reload live reload. The CLI + /// `--hmr`/`--no-hmr` flag takes precedence over this value. + pub hmr: bool, } /// Type of a resolved size budget. @@ -840,6 +871,22 @@ pub fn resolve_angular_project( .or_else(|| options.and_then(|o| o.external_dependencies.clone())) .unwrap_or_default(); + // Resolve serve `hmr`: base serve options, with the active + // configuration's serve override layered on top when present. Absent → + // `false` (full-reload live reload). The serve target reuses the same + // configuration name as the build (matching `ng serve -c `). + let serve_target = project.architect.as_ref().and_then(|a| a.serve.as_ref()); + let serve_options = serve_target.and_then(|st| st.options.as_ref()); + let serve_config = config_name.as_deref().and_then(|cn| { + serve_target + .and_then(|st| st.configurations.as_ref()) + .and_then(|configs| configs.get(cn)) + }); + let hmr = serve_config + .and_then(|sc| sc.hmr) + .or_else(|| serve_options.and_then(|o| o.hmr)) + .unwrap_or(false); + debug!( project = %name, output_path = %output_path.display(), @@ -872,6 +919,7 @@ pub fn resolve_angular_project( define, scripts, external_dependencies, + hmr, }) } @@ -1106,6 +1154,61 @@ mod tests { assert!(result.ts_config.ends_with("tsconfig.app.json")); } + #[test] + fn test_hmr_defaults_to_false_when_no_serve_target() { + let json = r#"{ + "projects": { + "app": { + "architect": { + "build": { "options": { "tsConfig": "tsconfig.json" } } + } + } + } + }"#; + let f = write_temp_json(json); + let result = resolve_angular_project(f.path(), None, None).unwrap(); + assert!(!result.hmr); + } + + #[test] + fn test_hmr_read_from_serve_options() { + let json = r#"{ + "projects": { + "app": { + "architect": { + "build": { "options": { "tsConfig": "tsconfig.json" } }, + "serve": { "options": { "hmr": true } } + } + } + } + }"#; + let f = write_temp_json(json); + let result = resolve_angular_project(f.path(), None, None).unwrap(); + assert!(result.hmr); + } + + #[test] + fn test_hmr_serve_configuration_overrides_base() { + let json = r#"{ + "projects": { + "app": { + "architect": { + "build": { "options": { "tsConfig": "tsconfig.json" } }, + "serve": { + "options": { "hmr": false }, + "configurations": { "development": { "hmr": true } } + } + } + } + } + }"#; + let f = write_temp_json(json); + let base = resolve_angular_project(f.path(), None, None).unwrap(); + assert!(!base.hmr, "base serve options keep hmr false"); + let dev = resolve_angular_project(f.path(), None, Some("development")).unwrap(); + assert!(dev.hmr, "development configuration overrides hmr to true"); + } + #[test] fn test_parse_object_output_path() { let json = r#"{