Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
584 changes: 563 additions & 21 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.15"
version = "0.10.16"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["lukekania"]
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,16 @@ When an `angular.json` is found, ngc-rs reads styles, assets, polyfills, and fil

### `ngc-rs serve`

Build the project, watch for source changes, and host `dist/` over HTTP with live reload — the `ng serve` equivalent for everyday Angular development:
Build the project, watch for source changes, and host `dist/` over HTTP (or HTTPS) with live reload — the `ng serve` equivalent for everyday Angular development:

```sh
ngc-rs serve --project tsconfig.app.json
ngc-rs serve --project tsconfig.app.json --host 0.0.0.0 --port 4300 --open

# HTTPS with an auto-generated self-signed certificate (browsers show the
# usual untrusted-certificate warning), or pass your own cert/key:
ngc-rs serve --project tsconfig.app.json --ssl
ngc-rs serve --project tsconfig.app.json --ssl --ssl-key dev.key --ssl-cert dev.crt
```

## Benchmark comparison
Expand Down
23 changes: 23 additions & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,23 @@ enum Commands {
/// overridden by these.
#[arg(long = "headers")]
headers: Option<String>,
/// Serve over HTTPS instead of HTTP. When set without `--ssl-key`
/// and `--ssl-cert`, a throwaway self-signed certificate is
/// generated for the bind host plus the loopback names; browsers
/// show the usual untrusted-certificate warning. Mirrors the `ssl`
/// option of `@angular/build:dev-server`.
#[arg(long)]
ssl: bool,
/// Path to a PEM-encoded private key for HTTPS. Requires `--ssl` and
/// `--ssl-cert`. Mirrors the `sslKey` option of
/// `@angular/build:dev-server`.
#[arg(long = "ssl-key")]
ssl_key: Option<PathBuf>,
/// Path to a PEM-encoded certificate for HTTPS. Requires `--ssl` and
/// `--ssl-key`. Mirrors the `sslCert` option of
/// `@angular/build:dev-server`.
#[arg(long = "ssl-cert")]
ssl_cert: Option<PathBuf>,
},
/// Extract translatable messages from every component template in the
/// project and emit a translation file (XLIFF 2.0 by default; XLIFF 1.2
Expand Down Expand Up @@ -390,6 +407,9 @@ fn main() {
serve_path,
allowed_hosts,
headers,
ssl,
ssl_key,
ssl_cert,
} => {
let parsed_headers = match parse_header_overrides(headers.as_deref()) {
Ok(h) => h,
Expand All @@ -407,6 +427,9 @@ fn main() {
serve_path.as_deref(),
&allowed_hosts,
&parsed_headers,
ssl,
ssl_key.as_deref(),
ssl_cert.as_deref(),
) {
eprintln!("{} {e}", "Error:".red().bold());
process::exit(1);
Expand Down
122 changes: 118 additions & 4 deletions crates/cli/src/serve_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use std::sync::mpsc::channel;
use std::sync::Arc;

use colored::Colorize;
use ngc_dev_server::{DevServer, DevServerConfig, DevServerEvent};
use ngc_dev_server::{DevServer, DevServerConfig, DevServerEvent, TlsConfig};
use ngc_diagnostics::{NgcError, NgcResult};
use ngc_watch::{Watcher, WatcherConfig};

Expand All @@ -37,6 +37,9 @@ pub fn run(
serve_path: Option<&str>,
allowed_hosts: &[String],
headers: &[(String, String)],
ssl: bool,
ssl_key: Option<&Path>,
ssl_cert: Option<&Path>,
) -> NgcResult<()> {
run_with_stop(
project,
Expand All @@ -47,10 +50,60 @@ pub fn run(
serve_path,
allowed_hosts,
headers,
ssl,
ssl_key,
ssl_cert,
install_ctrlc,
)
}

/// Resolve the `--ssl`/`--ssl-key`/`--ssl-cert` flags into an optional
/// [`TlsConfig`].
///
/// * `ssl` off → `None` (plain HTTP), and supplying a key/cert path without
/// `--ssl` is rejected so a typo doesn't silently serve over HTTP.
/// * `ssl` on with both a key and cert path → load that PEM material.
/// * `ssl` on with only one of the two → an error, since both halves are
/// required.
/// * `ssl` on with neither → mint a throwaway self-signed certificate for
/// the bind host (matching `@angular/build:dev-server`).
fn resolve_tls(
ssl: bool,
ssl_key: Option<&Path>,
ssl_cert: Option<&Path>,
host: &str,
) -> NgcResult<Option<TlsConfig>> {
if !ssl {
if ssl_key.is_some() || ssl_cert.is_some() {
return Err(NgcError::ServeError {
message: "--ssl-key/--ssl-cert require --ssl to be set".to_string(),
});
}
return Ok(None);
}

match (ssl_key, ssl_cert) {
(Some(key_path), Some(cert_path)) => {
let key_pem = std::fs::read(key_path).map_err(|source| NgcError::Io {
path: key_path.to_path_buf(),
source,
})?;
let cert_pem = std::fs::read(cert_path).map_err(|source| NgcError::Io {
path: cert_path.to_path_buf(),
source,
})?;
Ok(Some(TlsConfig::from_pem(cert_pem, key_pem)))
}
(None, None) => Ok(Some(TlsConfig::self_signed(&[host.to_string()])?)),
(Some(_), None) => Err(NgcError::ServeError {
message: "--ssl-key was set without --ssl-cert; both are required".to_string(),
}),
(None, Some(_)) => Err(NgcError::ServeError {
message: "--ssl-cert was set without --ssl-key; both are required".to_string(),
}),
}
}

/// Variant of [`run`] that lets the caller decide how the shutdown flag is
/// armed. Tests use a no-op installer so the watcher loop can be exited via
/// the returned [`Arc<AtomicBool>`] without touching the real signal
Expand All @@ -65,8 +118,13 @@ pub(crate) fn run_with_stop(
serve_path: Option<&str>,
allowed_hosts: &[String],
headers: &[(String, String)],
ssl: bool,
ssl_key: Option<&Path>,
ssl_cert: Option<&Path>,
install_stop: impl FnOnce(Arc<AtomicBool>),
) -> NgcResult<()> {
let tls = resolve_tls(ssl, ssl_key, ssl_cert, host)?;

let out_dir = crate::resolve_out_dir(project, None, configuration)?;
let mut cache = BuildCache::new();

Expand Down Expand Up @@ -100,11 +158,13 @@ pub(crate) fn run_with_stop(
.with_port(port)
.with_serve_path(normalized_serve_path.as_deref())
.with_allowed_hosts(allowed_hosts.iter().cloned())
.with_headers(headers.iter().cloned());
.with_headers(headers.iter().cloned())
.with_tls(tls);
let server = DevServer::start(cfg, event_rx)?;
let scheme = server.scheme();
let url = match server.serve_path() {
Some(prefix) => format!("http://{}{}", server.addr(), prefix),
None => format!("http://{}", server.addr()),
Some(prefix) => format!("{scheme}://{}{}", server.addr(), prefix),
None => format!("{scheme}://{}", server.addr()),
};
eprintln!(
"{} {}",
Expand Down Expand Up @@ -335,6 +395,60 @@ mod tests {
assert_eq!(file.as_deref(), Some(Path::new("/proj/src/app.ts")));
}

#[test]
fn resolve_tls_disabled_returns_none() {
assert!(resolve_tls(false, None, None, "localhost")
.expect("ok")
.is_none());
}

#[test]
fn resolve_tls_rejects_key_or_cert_without_ssl() {
assert!(resolve_tls(false, Some(Path::new("/k")), None, "localhost").is_err());
assert!(resolve_tls(false, None, Some(Path::new("/c")), "localhost").is_err());
}

#[test]
fn resolve_tls_auto_generates_when_ssl_without_paths() {
let tls = resolve_tls(true, None, None, "localhost")
.expect("ok")
.expect("some tls");
// Round-trips through the dev server's SslConfig as PEM bytes; just
// confirm something was minted.
let _ = tls; // opaque material; presence is the assertion
}

#[test]
fn resolve_tls_requires_both_key_and_cert() {
assert!(resolve_tls(true, Some(Path::new("/k")), None, "localhost").is_err());
assert!(resolve_tls(true, None, Some(Path::new("/c")), "localhost").is_err());
}

#[test]
fn resolve_tls_reads_explicit_pem_files() {
let dir = tempfile::tempdir().expect("tempdir");
let key = dir.path().join("dev.key");
let cert = dir.path().join("dev.crt");
std::fs::write(&key, b"KEYDATA").expect("write key");
std::fs::write(&cert, b"CERTDATA").expect("write cert");
let tls = resolve_tls(true, Some(&key), Some(&cert), "localhost")
.expect("ok")
.expect("some tls");
let _ = tls;
}

#[test]
fn resolve_tls_errors_when_explicit_pem_missing() {
let err = resolve_tls(
true,
Some(Path::new("/no/such/key.pem")),
Some(Path::new("/no/such/cert.pem")),
"localhost",
)
.expect_err("missing file should error");
assert!(matches!(err, NgcError::Io { .. }));
}

#[test]
fn build_failure_event_omits_path_for_pathless_errors() {
let err = NgcError::ServeError {
Expand Down
5 changes: 4 additions & 1 deletion crates/dev-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ publish = false

[dependencies]
ngc-diagnostics = { path = "../diagnostics" }
rcgen = "0.14.8"
serde_json = "1.0"
tiny_http = "0.12"
tiny_http = { version = "0.12", features = ["ssl-rustls"] }
tracing = "0.1"

[dev-dependencies]
rcgen = "0.14.8"
rustls = { version = "0.20", features = ["dangerous_configuration"] }
serde_json = "1.0"
tempfile = "3"
Loading