diff --git a/Cargo.lock b/Cargo.lock index 72f4222..9324fa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,7 +730,7 @@ dependencies = [ [[package]] name = "ngc-bundler" -version = "0.10.14" +version = "0.10.15" dependencies = [ "dashmap", "ngc-diagnostics", @@ -755,7 +755,7 @@ dependencies = [ [[package]] name = "ngc-dev-server" -version = "0.10.14" +version = "0.10.15" dependencies = [ "ngc-diagnostics", "serde_json", @@ -766,7 +766,7 @@ dependencies = [ [[package]] name = "ngc-diagnostics" -version = "0.10.14" +version = "0.10.15" dependencies = [ "serde_json", "thiserror", @@ -774,7 +774,7 @@ dependencies = [ [[package]] name = "ngc-linker" -version = "0.10.14" +version = "0.10.15" dependencies = [ "dashmap", "insta", @@ -792,7 +792,7 @@ dependencies = [ [[package]] name = "ngc-npm-resolver" -version = "0.10.14" +version = "0.10.15" dependencies = [ "dashmap", "ngc-diagnostics", @@ -807,7 +807,7 @@ dependencies = [ [[package]] name = "ngc-project-resolver" -version = "0.10.14" +version = "0.10.15" dependencies = [ "dashmap", "glob", @@ -823,7 +823,7 @@ dependencies = [ [[package]] name = "ngc-rs" -version = "0.10.14" +version = "0.10.15" dependencies = [ "base64", "clap", @@ -857,7 +857,7 @@ dependencies = [ [[package]] name = "ngc-template-compiler" -version = "0.10.14" +version = "0.10.15" dependencies = [ "insta", "ngc-diagnostics", @@ -879,7 +879,7 @@ dependencies = [ [[package]] name = "ngc-ts-transform" -version = "0.10.14" +version = "0.10.15" dependencies = [ "ngc-diagnostics", "oxc_allocator", @@ -898,7 +898,7 @@ dependencies = [ [[package]] name = "ngc-watch" -version = "0.10.14" +version = "0.10.15" dependencies = [ "ngc-diagnostics", "notify", diff --git a/Cargo.toml b/Cargo.toml index f5ae044..4ca96ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/cli", "crates/diagnostics", "crates/project-resolver", "crates/ts-transform", "crates/bundler", "crates/template-compiler", "crates/npm-resolver", "crates/linker", "crates/watch", "crates/dev-server"] [workspace.package] -version = "0.10.14" +version = "0.10.15" edition = "2021" license = "MIT OR Apache-2.0" authors = ["lukekania"] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 8f76733..3abff63 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -252,6 +252,16 @@ enum Commands { /// (`*.localhost`, `app.local`). #[arg(long = "allowed-hosts", value_delimiter = ',', num_args = 0..)] allowed_hosts: Vec, + /// Custom HTTP response headers to emit on every served response, + /// as a JSON object of header name → string value (e.g. + /// `--headers '{"Cross-Origin-Opener-Policy":"same-origin"}'`). + /// Mirrors the `headers` option of `@angular/build:dev-server`, + /// for serving production-like security headers (CSP, COOP), + /// CORS headers, or cache-control overrides in dev. Headers the + /// server sets itself (`Content-Type`, `Cache-Control`) are not + /// overridden by these. + #[arg(long = "headers")] + headers: Option, }, /// Extract translatable messages from every component template in the /// project and emit a translation file (XLIFF 2.0 by default; XLIFF 1.2 @@ -300,6 +310,32 @@ enum ExtractFormat { Arb, } +/// Parse the `serve --headers` JSON object into ordered name/value pairs. +/// +/// Accepts a JSON object whose values are all strings, e.g. +/// `{"Cross-Origin-Opener-Policy":"same-origin"}`. `None` (flag omitted) +/// yields an empty list. A non-object, malformed JSON, or a non-string +/// value is a hard error so a typo in `angular.json`'s `headers` surfaces +/// immediately rather than being silently dropped. +fn parse_header_overrides(raw: Option<&str>) -> Result, String> { + let Some(raw) = raw else { + return Ok(Vec::new()); + }; + let value: serde_json::Value = + serde_json::from_str(raw).map_err(|e| format!("--headers is not valid JSON: {e}"))?; + let serde_json::Value::Object(map) = value else { + return Err("--headers must be a JSON object of header name to string value".to_string()); + }; + let mut out = Vec::with_capacity(map.len()); + for (name, val) in map { + match val { + serde_json::Value::String(s) => out.push((name, s)), + _ => return Err(format!("--headers value for \"{name}\" must be a string")), + } + } + Ok(out) +} + fn main() { init_tracing(); let cli = Cli::parse(); @@ -353,7 +389,15 @@ fn main() { open, serve_path, allowed_hosts, + headers, } => { + let parsed_headers = match parse_header_overrides(headers.as_deref()) { + Ok(h) => h, + Err(e) => { + eprintln!("{} {e}", "Error:".red().bold()); + process::exit(1); + } + }; if let Err(e) = serve_cmd::run( &project, Some(&configuration), @@ -362,6 +406,7 @@ fn main() { open, serve_path.as_deref(), &allowed_hosts, + &parsed_headers, ) { eprintln!("{} {e}", "Error:".red().bold()); process::exit(1); @@ -3791,4 +3836,48 @@ mod tests { "translated bundle must hash differently per locale" ); } + + #[test] + fn parse_header_overrides_none_yields_empty() { + assert_eq!(parse_header_overrides(None).unwrap(), Vec::new()); + } + + #[test] + fn parse_header_overrides_parses_a_json_object() { + let parsed = parse_header_overrides(Some( + r#"{"Cross-Origin-Opener-Policy":"same-origin","X-Frame-Options":"DENY"}"#, + )) + .unwrap(); + // serde_json's Map iterates keys in sorted order. + assert_eq!( + parsed, + vec![ + ( + "Cross-Origin-Opener-Policy".to_string(), + "same-origin".to_string() + ), + ("X-Frame-Options".to_string(), "DENY".to_string()), + ] + ); + } + + #[test] + fn parse_header_overrides_rejects_malformed_json() { + assert!(parse_header_overrides(Some("{not json")).is_err()); + } + + #[test] + fn parse_header_overrides_rejects_non_object_json() { + let err = parse_header_overrides(Some(r#"["X-Foo"]"#)).unwrap_err(); + assert!(err.contains("JSON object"), "got: {err}"); + } + + #[test] + fn parse_header_overrides_rejects_non_string_values() { + let err = parse_header_overrides(Some(r#"{"X-Foo":123}"#)).unwrap_err(); + assert!( + err.contains("X-Foo") && err.contains("string"), + "got: {err}" + ); + } } diff --git a/crates/cli/src/serve_cmd.rs b/crates/cli/src/serve_cmd.rs index d0f989a..7ea2119 100644 --- a/crates/cli/src/serve_cmd.rs +++ b/crates/cli/src/serve_cmd.rs @@ -27,6 +27,7 @@ use crate::watch_cmd::{is_ts_path, watch_root}; /// Run the `serve` subcommand: bring up the dev server, drive the watcher, /// and block until Ctrl+C. +#[allow(clippy::too_many_arguments)] pub fn run( project: &Path, configuration: Option<&str>, @@ -35,6 +36,7 @@ pub fn run( open: bool, serve_path: Option<&str>, allowed_hosts: &[String], + headers: &[(String, String)], ) -> NgcResult<()> { run_with_stop( project, @@ -44,6 +46,7 @@ pub fn run( open, serve_path, allowed_hosts, + headers, install_ctrlc, ) } @@ -61,6 +64,7 @@ pub(crate) fn run_with_stop( open: bool, serve_path: Option<&str>, allowed_hosts: &[String], + headers: &[(String, String)], install_stop: impl FnOnce(Arc), ) -> NgcResult<()> { let out_dir = crate::resolve_out_dir(project, None, configuration)?; @@ -95,7 +99,8 @@ pub(crate) fn run_with_stop( .with_host(host.to_string()) .with_port(port) .with_serve_path(normalized_serve_path.as_deref()) - .with_allowed_hosts(allowed_hosts.iter().cloned()); + .with_allowed_hosts(allowed_hosts.iter().cloned()) + .with_headers(headers.iter().cloned()); let server = DevServer::start(cfg, event_rx)?; let url = match server.serve_path() { Some(prefix) => format!("http://{}{}", server.addr(), prefix), diff --git a/crates/dev-server/src/lib.rs b/crates/dev-server/src/lib.rs index de1c226..65b6b35 100644 --- a/crates/dev-server/src/lib.rs +++ b/crates/dev-server/src/lib.rs @@ -96,6 +96,12 @@ pub struct DevServerConfig { /// User-supplied `allowedHosts` patterns. Empty (= default) means /// `auto`: loopback hosts plus the bind host. See [`AllowedHosts`]. pub allowed_hosts: Vec, + /// Custom HTTP response headers to emit on every served response + /// (static assets, the SPA-fallback `index.html`, and the SSE + /// live-reload stream). Mirrors `@angular/build:dev-server`'s + /// `headers` option. Header names the server sets itself are never + /// overridden by these — see [`CustomHeaders`]. + pub headers: Vec<(String, String)>, } impl DevServerConfig { @@ -108,6 +114,7 @@ impl DevServerConfig { port: 4200, serve_path: None, allowed_hosts: Vec::new(), + headers: Vec::new(), } } @@ -141,6 +148,22 @@ impl DevServerConfig { self.allowed_hosts = hosts.into_iter().map(Into::into).collect(); self } + + /// Replace the custom response headers emitted on every served + /// response. See [`CustomHeaders`] for how reserved headers (the ones + /// the server sets itself) are protected from being clobbered. + pub fn with_headers(mut self, headers: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + self.headers = headers + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(); + self + } } /// Normalize a `servePath` string to the canonical `/foo/` form. @@ -303,6 +326,88 @@ fn strip_port(host: &str) -> &str { } } +/// Custom HTTP response headers emitted on every served response. +/// +/// Mirrors `@angular/build:dev-server`'s `headers` option, letting a +/// project configure production-like security headers (CSP, +/// `Cross-Origin-Opener-Policy`, …), CORS headers, or cache-control +/// overrides for the dev server. +/// +/// Two invariants matter: +/// +/// * **Validated once.** Each name/value pair is checked against +/// `tiny_http`'s header parser at construction time; an invalid entry is +/// dropped with a warning rather than failing every request. +/// * **Never clobbers server headers.** Headers the dev server sets itself +/// (the response `Content-Type`, the `Cache-Control` on static files, +/// and the SSE stream's `Connection` / `Access-Control-Allow-Origin`) +/// take precedence — a user `headers` entry for one of those names is +/// skipped for that response so the server stays correct. +#[derive(Debug, Clone, Default)] +pub struct CustomHeaders { + headers: Vec<(String, String)>, +} + +impl CustomHeaders { + /// Validate and retain the user-supplied `headers` map. Entries with a + /// blank name, or a name/value `tiny_http` rejects, are dropped with a + /// `warn` so a typo in `angular.json` is visible without taking the + /// whole dev server down. + pub fn resolve(raw: &[(String, String)]) -> Self { + let mut headers = Vec::with_capacity(raw.len()); + for (name, value) in raw { + let name = name.trim(); + if name.is_empty() { + continue; + } + if Header::from_bytes(name.as_bytes(), value.as_bytes()).is_err() { + tracing::warn!(header = %name, "ignoring invalid custom response header"); + continue; + } + headers.push((name.to_string(), value.clone())); + } + Self { headers } + } + + /// `true` when no custom headers are configured. + pub fn is_empty(&self) -> bool { + self.headers.is_empty() + } + + /// Add every configured header to `resp`, skipping any whose name + /// matches (case-insensitively) an entry in `reserved` — those the + /// server already set and must not let a user value clobber. + fn apply(&self, resp: &mut Response, reserved: &[&str]) { + for (name, value) in &self.headers { + if reserved.iter().any(|r| r.eq_ignore_ascii_case(name)) { + continue; + } + // Pre-validated in `resolve`, so `from_bytes` can't fail here; + // ignore the (impossible) error rather than propagating it. + if let Ok(h) = Header::from_bytes(name.as_bytes(), value.as_bytes()) { + resp.add_header(h); + } + } + } + + /// Render the configured headers as raw `Name: value\r\n` lines for the + /// hand-written SSE response head, skipping any reserved name. The + /// returned string is empty when nothing applies. + fn header_lines(&self, reserved: &[&str]) -> String { + let mut out = String::new(); + for (name, value) in &self.headers { + if reserved.iter().any(|r| r.eq_ignore_ascii_case(name)) { + continue; + } + out.push_str(name); + out.push_str(": "); + out.push_str(value); + out.push_str("\r\n"); + } + out + } +} + /// Handle to a running dev server. /// /// Dropping the handle stops the server and closes any open SSE connections. @@ -362,6 +467,7 @@ impl DevServer { let serve_path_for_loop = serve_path.clone(); 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 join = thread::Builder::new() .name("ngc-dev-server-accept".into()) .spawn(move || { @@ -371,6 +477,7 @@ impl DevServer { request_clients, serve_path_for_loop, allowed_hosts_for_loop, + custom_headers, ) }) .map_err(|e| NgcError::ServeError { @@ -506,16 +613,23 @@ fn serve_loop( clients: SseClients, serve_path: Option, allowed_hosts: Arc, + headers: Arc, ) { for request in server.incoming_requests() { let root = root.clone(); let clients = Arc::clone(&clients); let serve_path = serve_path.clone(); let allowed_hosts = Arc::clone(&allowed_hosts); + let headers = Arc::clone(&headers); thread::spawn(move || { - if let Err(e) = - handle_request(request, &root, &clients, serve_path.as_deref(), &allowed_hosts) - { + if let Err(e) = handle_request( + request, + &root, + &clients, + serve_path.as_deref(), + &allowed_hosts, + &headers, + ) { tracing::warn!(error = %e, "dev server request failed"); } }); @@ -528,6 +642,7 @@ fn handle_request( clients: &SseClients, serve_path: Option<&str>, allowed_hosts: &AllowedHosts, + headers: &CustomHeaders, ) -> NgcResult<()> { if !matches!(request.method(), Method::Get | Method::Head) { let resp = Response::from_string("method not allowed").with_status_code(StatusCode(405)); @@ -551,10 +666,10 @@ fn handle_request( }; if stripped == "/__ngc_reload" { - return handle_sse(request, clients); + return handle_sse(request, clients, headers); } - serve_static(request, root, stripped, serve_path) + serve_static(request, root, stripped, serve_path, headers) } /// Read the request's `Host:` header value, or return the empty string when @@ -624,18 +739,32 @@ fn strip_serve_path<'a>(path: &'a str, serve_path: Option<&str>) -> Option<&'a s None } -fn handle_sse(request: tiny_http::Request, clients: &SseClients) -> NgcResult<()> { - let response_head = b"HTTP/1.1 200 OK\r\n\ -Content-Type: text/event-stream\r\n\ -Cache-Control: no-cache\r\n\ -Connection: keep-alive\r\n\ -Access-Control-Allow-Origin: *\r\n\ -\r\n\ -: connected\n\n"; +fn handle_sse( + request: tiny_http::Request, + clients: &SseClients, + headers: &CustomHeaders, +) -> NgcResult<()> { + // The SSE stream sets these itself; a user `headers` entry for any of + // them is skipped so the event-stream contract stays intact. + const SSE_RESERVED: &[&str] = &[ + "Content-Type", + "Cache-Control", + "Connection", + "Access-Control-Allow-Origin", + ]; + let mut response_head = String::from( + "HTTP/1.1 200 OK\r\n\ + Content-Type: text/event-stream\r\n\ + Cache-Control: no-cache\r\n\ + Connection: keep-alive\r\n\ + Access-Control-Allow-Origin: *\r\n", + ); + response_head.push_str(&headers.header_lines(SSE_RESERVED)); + response_head.push_str("\r\n: connected\n\n"); let mut writer = request.into_writer(); writer - .write_all(response_head) + .write_all(response_head.as_bytes()) .and_then(|_| writer.flush()) .map_err(|e| NgcError::ServeError { message: format!("could not start SSE stream: {e}"), @@ -653,6 +782,7 @@ fn serve_static( root: &Path, url_path: &str, serve_path: Option<&str>, + headers: &CustomHeaders, ) -> NgcResult<()> { let decoded = decode_path(url_path); let candidate = match resolve_under_root(root, &decoded) { @@ -664,8 +794,8 @@ fn serve_static( }; match pick_file(&candidate) { - Some(file_path) => respond_with_file(request, &file_path, serve_path), - None => spa_fallback(request, root, serve_path), + Some(file_path) => respond_with_file(request, &file_path, serve_path, headers), + None => spa_fallback(request, root, serve_path, headers), } } @@ -686,10 +816,11 @@ fn spa_fallback( request: tiny_http::Request, root: &Path, serve_path: Option<&str>, + headers: &CustomHeaders, ) -> NgcResult<()> { let index = root.join("index.html"); if index.is_file() { - respond_with_file(request, &index, serve_path) + respond_with_file(request, &index, serve_path, headers) } else { let resp = Response::from_string("not found").with_status_code(StatusCode(404)); request.respond(resp).map_err(io_err) @@ -700,6 +831,7 @@ fn respond_with_file( request: tiny_http::Request, path: &Path, serve_path: Option<&str>, + headers: &CustomHeaders, ) -> NgcResult<()> { let bytes = std::fs::read(path).map_err(|e| NgcError::Io { path: path.to_path_buf(), @@ -716,6 +848,10 @@ fn respond_with_file( let mut resp = Response::from_data(body); resp.add_header(header("Content-Type", mime)?); resp.add_header(header("Cache-Control", "no-cache")?); + // Apply user-configured headers last, but never let them clobber the + // `Content-Type` (correct for the file) or the dev-server + // `Cache-Control` (live reload depends on responses not being cached). + headers.apply(&mut resp, &["Content-Type", "Cache-Control"]); request.respond(resp).map_err(io_err) } @@ -1244,10 +1380,7 @@ mod tests { fn allowed_hosts_explicit_without_auto_does_not_accept_bind_host() { // Without "auto", the bind host is NOT auto-allowed — the user // explicitly listed which non-loopback hosts to trust. - let ah = AllowedHosts::resolve( - &["my-app.ngrok.io".to_string()], - "192.168.1.10", - ); + let ah = AllowedHosts::resolve(&["my-app.ngrok.io".to_string()], "192.168.1.10"); assert!(!ah.is_allowed("192.168.1.10")); assert!(ah.is_allowed("my-app.ngrok.io")); } @@ -1291,4 +1424,63 @@ mod tests { assert!(ah.is_allowed("localhost")); assert!(!ah.is_allowed("nope.example")); } + + fn pair(name: &str, value: &str) -> (String, String) { + (name.to_string(), value.to_string()) + } + + #[test] + fn custom_headers_empty_by_default() { + assert!(CustomHeaders::default().is_empty()); + assert!(CustomHeaders::resolve(&[]).is_empty()); + } + + #[test] + fn custom_headers_resolve_keeps_valid_entries() { + let ch = CustomHeaders::resolve(&[ + pair("X-Frame-Options", "DENY"), + pair("Cross-Origin-Opener-Policy", "same-origin"), + ]); + assert!(!ch.is_empty()); + assert_eq!(ch.headers.len(), 2); + } + + #[test] + fn custom_headers_resolve_drops_blank_names() { + let ch = CustomHeaders::resolve(&[pair("", "x"), pair(" ", "y"), pair("X-Ok", "z")]); + assert_eq!(ch.headers.len(), 1); + assert_eq!(ch.headers[0].0, "X-Ok"); + } + + #[test] + fn custom_headers_resolve_drops_invalid_names() { + // A non-ASCII header name cannot be represented on the wire and is + // dropped rather than failing every request. + let ch = CustomHeaders::resolve(&[pair("Föö", "bar")]); + assert!(ch.is_empty()); + } + + #[test] + fn custom_headers_header_lines_skips_reserved_names() { + let ch = CustomHeaders::resolve(&[ + pair("Content-Type", "text/evil"), + pair("X-Frame-Options", "DENY"), + ]); + let lines = ch.header_lines(&["Content-Type", "Cache-Control"]); + assert!(!lines.to_ascii_lowercase().contains("content-type")); + assert!(lines.contains("X-Frame-Options: DENY\r\n")); + } + + #[test] + fn custom_headers_header_lines_reserved_match_is_case_insensitive() { + let ch = CustomHeaders::resolve(&[pair("content-type", "x")]); + assert!(ch.header_lines(&["Content-Type"]).is_empty()); + } + + #[test] + fn devserver_config_with_headers_stores_pairs() { + let cfg = DevServerConfig::new("/tmp/dist").with_headers([("X-A", "1"), ("X-B", "2")]); + assert_eq!(cfg.headers.len(), 2); + assert_eq!(cfg.headers[0], ("X-A".to_string(), "1".to_string())); + } } diff --git a/crates/dev-server/tests/integration.rs b/crates/dev-server/tests/integration.rs index b043474..3f26f14 100644 --- a/crates/dev-server/tests/integration.rs +++ b/crates/dev-server/tests/integration.rs @@ -443,17 +443,12 @@ fn unprefixed_request_returns_404_when_serve_path_set() { assert_eq!(http_get(fx.server.addr(), "/__ngc_reload").status, 404); } -fn http_get_with_host( - addr: std::net::SocketAddr, - path: &str, - host_header: &str, -) -> HttpResponse { +fn http_get_with_host(addr: std::net::SocketAddr, path: &str, host_header: &str) -> HttpResponse { let mut stream = TcpStream::connect(addr).expect("connect"); stream .set_read_timeout(Some(Duration::from_secs(5))) .expect("read timeout"); - let req = - format!("GET {path} HTTP/1.1\r\nHost: {host_header}\r\nConnection: close\r\n\r\n"); + let req = format!("GET {path} HTTP/1.1\r\nHost: {host_header}\r\nConnection: close\r\n\r\n"); stream.write_all(req.as_bytes()).expect("write"); stream.flush().expect("flush"); @@ -507,9 +502,18 @@ fn allowed_hosts_fixture(patterns: &[&str]) -> Fixture { #[test] fn default_allowed_hosts_accept_loopback_and_403_others() { let fx = allowed_hosts_fixture(&[]); - assert_eq!(http_get_with_host(fx.server.addr(), "/", "localhost").status, 200); - assert_eq!(http_get_with_host(fx.server.addr(), "/", "127.0.0.1").status, 200); - assert_eq!(http_get_with_host(fx.server.addr(), "/", "[::1]").status, 200); + assert_eq!( + http_get_with_host(fx.server.addr(), "/", "localhost").status, + 200 + ); + assert_eq!( + http_get_with_host(fx.server.addr(), "/", "127.0.0.1").status, + 200 + ); + assert_eq!( + http_get_with_host(fx.server.addr(), "/", "[::1]").status, + 200 + ); let blocked = http_get_with_host(fx.server.addr(), "/", "my-app.ngrok.io"); assert_eq!(blocked.status, 403); let body = std::str::from_utf8(&blocked.body).unwrap_or(""); @@ -532,7 +536,10 @@ fn explicit_allowed_host_lets_ngrok_traffic_through() { 200 ); // Loopback still works. - assert_eq!(http_get_with_host(fx.server.addr(), "/", "localhost").status, 200); + assert_eq!( + http_get_with_host(fx.server.addr(), "/", "localhost").status, + 200 + ); // Anything else is still blocked. assert_eq!( http_get_with_host(fx.server.addr(), "/", "other.ngrok.io").status, @@ -583,3 +590,133 @@ fn prefixed_sse_channel_is_reachable_under_prefix() { } assert!(saw_event_stream); } + +/// Build a fixture whose dev server is configured with the given custom +/// response `headers`. +fn headers_fixture(headers: &[(&str, &str)]) -> Fixture { + let root = TempDir::new().expect("tempdir"); + write_file( + root.path(), + "index.html", + b"

hi

", + ); + write_file(root.path(), "main.js", b"console.log('hello');"); + + let cfg = DevServerConfig::new(root.path()) + .with_port(0) + .with_headers(headers.iter().map(|(k, v)| (k.to_string(), v.to_string()))); + let (_tx, rx) = channel::(); + let server = DevServer::start(cfg, rx).expect("start dev server"); + Fixture { + server, + _root: root, + } +} + +#[test] +fn custom_headers_are_emitted_on_static_assets() { + let fx = headers_fixture(&[("Cross-Origin-Opener-Policy", "same-origin")]); + let resp = http_get(fx.server.addr(), "/main.js"); + assert_eq!(resp.status, 200); + assert_eq!( + resp.header("Cross-Origin-Opener-Policy"), + Some("same-origin") + ); +} + +#[test] +fn custom_headers_are_emitted_on_index_html() { + let fx = headers_fixture(&[("Cross-Origin-Opener-Policy", "same-origin")]); + let resp = http_get(fx.server.addr(), "/"); + assert_eq!(resp.status, 200); + assert_eq!( + resp.header("Cross-Origin-Opener-Policy"), + Some("same-origin") + ); +} + +#[test] +fn custom_headers_are_emitted_on_spa_fallback() { + let fx = headers_fixture(&[("X-Frame-Options", "DENY")]); + // A deep client-side route resolves to no file and falls back to + // index.html — the custom headers must ride along. + let resp = http_get(fx.server.addr(), "/users/42/profile"); + assert_eq!(resp.status, 200); + assert_eq!(resp.header("X-Frame-Options"), Some("DENY")); +} + +#[test] +fn multiple_custom_headers_are_all_emitted() { + let fx = headers_fixture(&[ + ("X-Frame-Options", "DENY"), + ("X-Content-Type-Options", "nosniff"), + ]); + let resp = http_get(fx.server.addr(), "/main.js"); + assert_eq!(resp.header("X-Frame-Options"), Some("DENY")); + assert_eq!(resp.header("X-Content-Type-Options"), Some("nosniff")); +} + +#[test] +fn custom_content_type_header_does_not_clobber_the_real_one() { + // A user `Content-Type` entry must never override the MIME type the + // server picked for the served file. + let fx = headers_fixture(&[("Content-Type", "text/plain")]); + let resp = http_get(fx.server.addr(), "/main.js"); + assert_eq!(resp.status, 200); + let ct = resp.header("Content-Type").expect("content-type"); + assert!( + ct.starts_with("application/javascript"), + "user Content-Type clobbered the server's: {ct}" + ); +} + +#[test] +fn custom_cache_control_header_does_not_clobber_the_dev_server_one() { + // Live reload depends on responses not being cached; a user + // `Cache-Control` entry must not override the dev server's `no-cache`. + let fx = headers_fixture(&[("Cache-Control", "max-age=31536000")]); + let resp = http_get(fx.server.addr(), "/main.js"); + assert_eq!(resp.header("Cache-Control"), Some("no-cache")); +} + +#[test] +fn no_custom_headers_keeps_responses_unchanged() { + let fx = headers_fixture(&[]); + let resp = http_get(fx.server.addr(), "/main.js"); + assert_eq!(resp.status, 200); + assert!(resp.header("Cross-Origin-Opener-Policy").is_none()); +} + +#[test] +fn custom_headers_are_emitted_on_the_sse_stream() { + let fx = headers_fixture(&[("Cross-Origin-Opener-Policy", "same-origin")]); + let mut stream = TcpStream::connect(fx.server.addr()).expect("connect"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("read timeout"); + let req = "GET /__ngc_reload HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept: text/event-stream\r\n\r\n"; + stream.write_all(req.as_bytes()).expect("write"); + stream.flush().expect("flush"); + + let mut reader = BufReader::new(stream); + let mut status_line = String::new(); + reader.read_line(&mut status_line).expect("status line"); + assert!(status_line.contains("200"), "got {status_line}"); + + let mut saw_header = false; + loop { + let mut line = String::new(); + let n = reader.read_line(&mut line).expect("header"); + if n == 0 || line == "\r\n" { + break; + } + if line + .to_ascii_lowercase() + .starts_with("cross-origin-opener-policy:") + { + assert!(line.to_ascii_lowercase().contains("same-origin")); + saw_header = true; + } + } + assert!(saw_header, "custom header missing from SSE response head"); +} diff --git a/packages/builder/schemas/dev-server.json b/packages/builder/schemas/dev-server.json index 449ab8b..324fe32 100644 --- a/packages/builder/schemas/dev-server.json +++ b/packages/builder/schemas/dev-server.json @@ -68,6 +68,11 @@ "type": "array", "items": { "type": "string" }, "description": "List of host names the dev server's Host-header check accepts. Loopback hosts (localhost, 127.0.0.1, [::1]) are always allowed. The special value \"all\" disables the check entirely. The special value \"auto\" additionally accepts the configured bind host. Use this to expose the dev server through tunneling proxies (ngrok, Cloudflare Tunnel, GitHub Codespaces) or non-default local hostnames (*.localhost, app.local)." + }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Custom HTTP response headers emitted on every served response (static assets, the SPA-fallback index.html, and the SSE live-reload stream). Use this to serve production-like security headers (CSP, Cross-Origin-Opener-Policy), CORS headers, or cache-control overrides in dev. Headers the dev server sets itself (Content-Type, Cache-Control) are not overridden. Headers are not added to proxy-forwarded responses, which keep their upstream headers." } }, "additionalProperties": false diff --git a/packages/builder/src/serve/__tests__/options.test.ts b/packages/builder/src/serve/__tests__/options.test.ts index 9acd993..584c5ba 100644 --- a/packages/builder/src/serve/__tests__/options.test.ts +++ b/packages/builder/src/serve/__tests__/options.test.ts @@ -135,6 +135,60 @@ describe('translateOptions', () => { ).not.toContain('--allowed-hosts'); expect(translateOptions(base, '/ws').args).not.toContain('--allowed-hosts'); }); + + it('forwards a headers map as a JSON --headers arg', () => { + const t = translateOptions( + { + ...base, + headers: { 'Cross-Origin-Opener-Policy': 'same-origin' }, + }, + '/ws', + ); + const idx = t.args.indexOf('--headers'); + expect(idx).toBeGreaterThanOrEqual(0); + expect(JSON.parse(t.args[idx + 1])).toEqual({ + 'Cross-Origin-Opener-Policy': 'same-origin', + }); + }); + + it('forwards multiple headers in a single --headers arg', () => { + const t = translateOptions( + { + ...base, + headers: { 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' }, + }, + '/ws', + ); + const idx = t.args.indexOf('--headers'); + expect(JSON.parse(t.args[idx + 1])).toEqual({ + 'X-Frame-Options': 'DENY', + 'X-Content-Type-Options': 'nosniff', + }); + }); + + it('trims header names and drops empty-name / non-string entries', () => { + const t = translateOptions( + { + ...base, + headers: { + ' X-Trim ': 'ok', + '': 'dropped', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'X-Bad': 123 as any, + }, + }, + '/ws', + ); + const idx = t.args.indexOf('--headers'); + expect(JSON.parse(t.args[idx + 1])).toEqual({ 'X-Trim': 'ok' }); + }); + + it('omits --headers when the map is empty or unset', () => { + expect( + translateOptions({ ...base, headers: {} }, '/ws').args, + ).not.toContain('--headers'); + expect(translateOptions(base, '/ws').args).not.toContain('--headers'); + }); }); describe('formatUrl', () => { diff --git a/packages/builder/src/serve/options.ts b/packages/builder/src/serve/options.ts index 1f7a613..c3e4d62 100644 --- a/packages/builder/src/serve/options.ts +++ b/packages/builder/src/serve/options.ts @@ -16,6 +16,7 @@ export interface DevServerOptions extends json.JsonObject { watch: boolean | null; servePath: string | null; allowedHosts: string[] | null; + headers: { [key: string]: string } | null; } export interface TranslatedServeArgs { @@ -84,6 +85,10 @@ export function translateOptions( if (allowedHosts.length > 0) { args.push('--allowed-hosts', allowedHosts.join(',')); } + const headers = normalizeHeaders(raw.headers); + if (headers !== null) { + args.push('--headers', headers); + } return { args, @@ -148,6 +153,34 @@ function normalizeAllowedHosts(raw: string[] | null | undefined): string[] { return out; } +// Serialize the dev-server `headers` map into a compact JSON object string +// for the `--headers` CLI flag (the shape the Rust side parses). Header +// names are trimmed; entries with an empty name or a non-string value are +// dropped — the Rust side would reject the latter anyway, and dropping +// here keeps a stray null/number in angular.json from failing the build. +// Returns null when nothing survives so the caller can omit the flag. +function normalizeHeaders( + raw: { [key: string]: string } | null | undefined, +): string | null { + if (!raw || typeof raw !== 'object') { + return null; + } + const out: { [key: string]: string } = {}; + let count = 0; + for (const [key, value] of Object.entries(raw)) { + if (typeof value !== 'string') { + continue; + } + const name = key.trim(); + if (!name) { + continue; + } + out[name] = value; + count++; + } + return count > 0 ? JSON.stringify(out) : null; +} + function parseConfigurationFromBuildTarget(buildTarget?: string): string | null { if (!buildTarget) { return null;