From 8ee57a8575d61ecbaa89527abaf15c62c2514e66 Mon Sep 17 00:00:00 2001 From: lukekania Date: Mon, 18 May 2026 10:40:41 +0200 Subject: [PATCH 01/20] feat(template-compiler): bind `@if (expr; as alias)` value at runtime (#166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#165` stopped the build from breaking on the `; as alias` syntax by stripping the alias clause at codegen time. The alias itself was still unbound: references like `{{ alias.name }}` compiled to `ctx.alias` on the parent component, which is `undefined` for any component that does not happen to carry a field of that name. This change makes the alias actually flow through Angular's `ɵɵconditional` contract: - The truthy expression value is passed as `ɵɵconditional`'s second argument, so the matching template's embedded view receives it as its `_ctx` parameter. - The matching branch's child template function uses the alias name as its `_ctx` parameter, so body references like `{{ it.name }}` resolve to the local directly. - `@else if (expr; as )` works the same way, branch-for-branch — the alias-value chain mirrors the slot-selection ternary so each branch's truthy value lights up only when that branch matches. - Nested templates inside an aliased `@if` (e.g. a `@switch` case that reads `s.error`) bind the alias via a single `ɵɵnextContext()` walk back to the aliased ancestor's embedded view. Both the update prelude and listener closures emit this binding. - Pipes inside an `@if` condition (`state$ | async; as s`) now route through `compile_binding_expr` so `ɵɵpipe(...)` registers at the correct slot scope and the resulting `ɵɵpipeBind1(...)` form is reused as both `ɵɵconditional` arguments. Previously a piped condition compiled to a bitwise-OR expression. Codegen-shape regression tests pin the emission for the four shapes the fixture exercises (basic alias, no-alias parity, `@else if` per-branch alias, nested-scope alias). A new integration test under `crates/template-compiler/tests/` walks `compile_component` end-to-end for the same shapes. --- crates/template-compiler/src/codegen.rs | 416 +++++++++++++++--- .../tests/if_alias_binding_integration.rs | 195 ++++++++ 2 files changed, 553 insertions(+), 58 deletions(-) create mode 100644 crates/template-compiler/tests/if_alias_binding_integration.rs diff --git a/crates/template-compiler/src/codegen.rs b/crates/template-compiler/src/codegen.rs index 2c15c8f..638f039 100644 --- a/crates/template-compiler/src/codegen.rs +++ b/crates/template-compiler/src/codegen.rs @@ -23,8 +23,12 @@ pub struct IvyOutput { /// A single level in the template scope hierarchy. #[derive(Debug, Clone)] enum ScopeEntry { - /// An `@if`/`@else`/`@switch` embedded view — no local variables. - Conditional, + /// An `@if`/`@else if`/`@else`/`@switch` embedded view. `alias` is `Some` + /// when an `@if (expr; as )` / `@else if (expr; as )` clause + /// names the truthy value — that value is delivered to the inner template + /// as its `_ctx` parameter, and nested scopes can read it via + /// `ɵɵnextContext()`. + Conditional { alias: Option }, /// An `@for` embedded view — declares an item variable from `$implicit`. Repeater { item_name: String }, } @@ -1272,11 +1276,21 @@ impl IvyCodegen { ); self.child_counter += 1; + // `@if (expr; as alias)` exposes the truthy `expr` value as `alias` + // inside the body. The alias becomes this branch's template `_ctx` + // (Angular's `ɵɵconditional` semantics: its second arg is delivered + // to the matching embedded view as `_ctx`). + let if_alias = extract_block_alias(&block.condition).map(|s| s.to_string()); + // Extract root element tag name and attributes for the conditional host element. // Angular 21 passes the first child's tag and consts index to conditionalCreate // so the container is backed by a real DOM element with proper attributes. let (root_tag, root_attrs_idx) = get_root_element_info(&block.children, self); - let child = self.generate_child_template(&child_fn_name, &block.children); + let child = self.generate_child_template_with_alias( + &child_fn_name, + &block.children, + if_alias.as_deref(), + ); match (root_tag, root_attrs_idx) { (Some(ref tag), Some(idx)) => { self.creation.push(format!( @@ -1311,8 +1325,13 @@ impl IvyCodegen { .insert("\u{0275}\u{0275}conditionalCreate".to_string()); let ei_slot = self.slot_index; self.slot_index += 1; + let ei_alias = extract_block_alias(&branch.condition).map(|s| s.to_string()); let (ei_tag, ei_attrs) = get_root_element_info(&branch.children, self); - let child = self.generate_child_template(&fn_name, &branch.children); + let child = self.generate_child_template_with_alias( + &fn_name, + &branch.children, + ei_alias.as_deref(), + ); match (ei_tag, ei_attrs) { (Some(ref tag), Some(idx)) => self.creation.push(format!( "\u{0275}\u{0275}conditionalCreate({ei_slot}, {fn_name}, {}, {}, '{tag}', {idx});", @@ -1327,7 +1346,12 @@ impl IvyCodegen { child.decls, child.vars )), } - else_if_slots.push((branch.condition.clone(), fn_name.clone(), ei_slot)); + else_if_slots.push(( + branch.condition.clone(), + fn_name.clone(), + ei_slot, + ei_alias, + )); self.child_templates.push(child); } @@ -1362,17 +1386,36 @@ impl IvyCodegen { self.child_templates.push(child); } - // Update block: conditional with absolute slot indices + // Update block: conditional with absolute slot indices. + // + // For each branch we compile the (alias-stripped) condition through + // `compile_binding_expr` so pipes inside the condition (e.g. + // `state$ | async; as s`) register `ɵɵpipe(...)` at this parent + // template's slot space and resolve via `ɵɵpipeBind*` at runtime. + // The same compiled form is reused as `ɵɵconditional`'s second arg + // so the matching branch's `_ctx` carries the truthy value. self.add_advance(slot); - let cond_expr = build_conditional_expr( - &block.condition, - slot, - &else_if_slots, - &else_slot_info, - &self.local_vars, - ); - self.update - .push(format!("\u{0275}\u{0275}conditional({cond_expr});")); + let mut compiled_branches: Vec<(String, Option, u32)> = Vec::new(); + let if_stripped = strip_block_alias(&block.condition).to_string(); + let if_compiled = self.compile_binding_expr(&if_stripped); + compiled_branches.push((if_compiled, if_alias, slot)); + for (cond_raw, _fn_name, ei_slot, ei_alias) in &else_if_slots { + let stripped = strip_block_alias(cond_raw).to_string(); + let compiled = self.compile_binding_expr(&stripped); + compiled_branches.push((compiled, ei_alias.clone(), *ei_slot)); + } + + let any_alias = compiled_branches.iter().any(|(_, a, _)| a.is_some()); + let test_chain = build_test_chain(&compiled_branches, else_slot_info.as_ref()); + if any_alias { + let alias_chain = build_alias_value_chain(&compiled_branches); + self.update.push(format!( + "\u{0275}\u{0275}conditional({test_chain}, {alias_chain});" + )); + } else { + self.update + .push(format!("\u{0275}\u{0275}conditional({test_chain});")); + } self.var_count += 1; } @@ -1545,6 +1588,20 @@ impl IvyCodegen { &mut self, fn_name: &str, children: &[TemplateNode], + ) -> ChildTemplate { + self.generate_child_template_with_alias(fn_name, children, None) + } + + /// Like `generate_child_template`, but for an `@if (expr; as )` / + /// `@else if (expr; as )` body: the alias becomes the template + /// function's `_ctx` parameter so `{{ alias.x }}` inside the body resolves + /// to the truthy expression value at runtime, and references from nested + /// scopes walk back via `ɵɵnextContext()` to reach it. + fn generate_child_template_with_alias( + &mut self, + fn_name: &str, + children: &[TemplateNode], + alias: Option<&str>, ) -> ChildTemplate { // Save parent state. Note: self.consts is NOT saved/restored — all // templates within a component share one consts array (tView.consts), @@ -1558,7 +1615,19 @@ impl IvyCodegen { let parent_lets = self.let_declarations.clone(); let parent_refs = std::mem::take(&mut self.template_refs); let parent_ref_elements = std::mem::take(&mut self.template_ref_elements); - self.scope_stack.push(ScopeEntry::Conditional); + // `local_vars` is normally inherited by children (so a parent `@let` + // remains visible). For aliased `@if` we need to insert the alias and + // make sure it does NOT leak into sibling templates — snapshot here + // and restore at the end. Without an alias, keep the historic + // shared-mutation behavior so we don't regress sibling-template + // resolution. + let parent_locals = alias.map(|_| self.local_vars.clone()); + if let Some(name) = alias { + self.local_vars.insert(name.to_string()); + } + self.scope_stack.push(ScopeEntry::Conditional { + alias: alias.map(|n| n.to_string()), + }); // Reset namespace_state for this child template function (its runtime // namespace flag starts as HTML). The stack is left intact so nested // elements inherit the outer context's namespace. @@ -1569,10 +1638,10 @@ impl IvyCodegen { self.var_count = 0; self.pipe_var_offset = 0; self.last_update_slot = None; - // Don't clear let_declarations, local_vars, or consts — children - // inherit parent scope and share the component-level consts array. - // template_refs, however, is scoped per-template: refs live in a - // specific LView's slot space and can't cross TView boundaries. + // Don't clear let_declarations or consts — children inherit parent + // scope and share the component-level consts array. template_refs, + // however, is scoped per-template: refs live in a specific LView's + // slot space and can't cross TView boundaries. self.generate_nodes(children); @@ -1583,9 +1652,15 @@ impl IvyCodegen { // in the update block and ɵɵrestoreView + ɵɵnextContext in listeners. let has_listeners = self.creation.iter().any(|s| s.contains("listener")); - // Use `_ctx` as parameter name since we rebind `ctx` via ɵɵnextContext() - // in the update block. Using `ctx` for both would be a const redeclaration. - let mut code = format!("function {fn_name}(rf, _ctx) {{\n"); + // For `@if (expr; as alias)`, use the alias as the function parameter + // name so the body's references resolve to the truthy expression + // value directly (the value Angular passes as `ɵɵconditional`'s + // second arg becomes this template's `_ctx`). Otherwise keep the + // generic `_ctx` name — we rebind `ctx` via `ɵɵnextContext()` in the + // update block, so using `ctx` for both would be a const + // redeclaration. + let ctx_param = alias.unwrap_or("_ctx"); + let mut code = format!("function {fn_name}(rf, {ctx_param}) {{\n"); if !self.creation.is_empty() { code.push_str(" if (rf & 1) {\n"); if has_listeners { @@ -1644,6 +1719,9 @@ impl IvyCodegen { self.update = parent_update; self.let_declarations = parent_lets; self.template_refs = parent_refs; + if let Some(locals) = parent_locals { + self.local_vars = locals; + } let child_ref_elements = std::mem::replace(&mut self.template_ref_elements, parent_ref_elements); self.namespace_state = parent_ns_state; @@ -2297,6 +2375,7 @@ impl IvyCodegen { let mut code = String::new(); let depth = self.scope_stack.len(); let mut levels_consumed = 0; + let body_text = self.update.join("\n"); // Walk the scope stack from innermost (current) to outermost. // The stack represents [outermost, ..., innermost], so we iterate in reverse. @@ -2328,8 +2407,33 @@ impl IvyCodegen { ScopeEntry::Repeater { .. } => { // i=0: this is the current @for scope — item accessed via _ctx.$implicit } - ScopeEntry::Conditional => { - // Skip — no variables to extract from conditional scopes + ScopeEntry::Conditional { + alias: Some(alias_name), + } if i > 0 => { + // ANCESTOR @if with `; as alias`. The aliased value is the + // ancestor template's own `_ctx`, so `ɵɵnextContext()` from + // here yields it directly (no `$implicit` indirection like + // @for). Only emit when the body actually reads the alias. + if identifier_used_in(&body_text, alias_name) { + let steps = i - levels_consumed; + if steps == 1 { + code.push_str(&format!( + " const {alias_name} = \u{0275}\u{0275}nextContext();\n" + )); + } else if steps > 1 { + code.push_str(&format!( + " const {alias_name} = \u{0275}\u{0275}nextContext({steps});\n" + )); + } + if steps > 0 { + levels_consumed = i; + } + } + } + ScopeEntry::Conditional { .. } => { + // Skip — current scope's alias (if any) is bound as the + // template function parameter, and non-aliased + // conditionals contribute no variables. } } } @@ -2402,7 +2506,28 @@ impl IvyCodegen { } levels_consumed = i; } - ScopeEntry::Conditional => { + ScopeEntry::Conditional { + alias: Some(alias_name), + } if i > 0 => { + // Ancestor `@if (...; as alias)` — its `_ctx` IS the alias + // value, so a single `ɵɵnextContext()` (relative to the + // levels already consumed) yields it. The listener body + // closure captures it for later use. + let steps = i - levels_consumed; + if steps == 1 { + code.push_str(&format!( + "const {alias_name} = \u{0275}\u{0275}nextContext(); " + )); + } else if steps > 1 { + code.push_str(&format!( + "const {alias_name} = \u{0275}\u{0275}nextContext({steps}); " + )); + } + if steps > 0 { + levels_consumed = i; + } + } + ScopeEntry::Conditional { .. } => { // No implicit variable — still counts toward navigation depth. } } @@ -3294,49 +3419,45 @@ fn collect_ctx_rewrites( } } -/// Build a conditional expression for @if chains using absolute slot indices. -fn build_conditional_expr( - condition: &str, - if_slot: u32, - else_ifs: &[(String, String, u32)], - else_info: &Option<(String, u32)>, - locals: &BTreeSet, +/// Build `ɵɵconditional`'s first argument: a chained ternary that selects the +/// matching template slot. `branches` is `[(compiled_expr, alias?, slot), …]` +/// in source order (`@if`, then `@else if`s). When `else_info` is `Some`, its +/// slot is used as the fallback; otherwise the chain falls through to `-1`. +fn build_test_chain( + branches: &[(String, Option, u32)], + else_info: Option<&(String, u32)>, ) -> String { - let mut expr = format!( - "{} ? {} : ", - ctx_expr_with_locals(strip_block_alias(condition), locals), - if_slot - ); - - for (cond, _fn_name, slot) in else_ifs { - expr.push_str(&format!( - "{} ? {} : ", - ctx_expr_with_locals(strip_block_alias(cond), locals), - slot - )); + let mut expr = String::new(); + for (compiled, _alias, slot) in branches { + expr.push_str(&format!("{compiled} ? {slot} : ")); } - if let Some((_fn, slot)) = else_info { - expr.push_str(&format!("{}", slot)); + expr.push_str(&slot.to_string()); } else { expr.push_str("-1"); } + expr +} +/// Build `ɵɵconditional`'s second argument: a chained ternary that yields the +/// matching branch's truthy condition value (for branches that named an +/// alias via `; as `) or `null` (for branches that did not). The +/// chain shape mirrors `build_test_chain` so the same branch index lights up +/// both arguments in lock-step. +fn build_alias_value_chain(branches: &[(String, Option, u32)]) -> String { + let mut expr = String::new(); + for (compiled, alias, _slot) in branches { + let value = if alias.is_some() { compiled.as_str() } else { "null" }; + expr.push_str(&format!("{compiled} ? {value} : ")); + } + expr.push_str("null"); expr } /// Strip the `; as ` suffix from an `@if` / `@else if` control-flow -/// expression so the codegen emits valid JavaScript for `ɵɵconditional(...)`. -/// -/// Angular's grammar allows `@if (expr; as alias) { ... }` to expose the -/// truthy value of `expr` as `alias` inside the body. The pest grammar -/// captures the whole `expr; as alias` as a single condition string; without -/// this strip the codegen emits `ɵɵconditional(expr; as alias ? slot : -1)` -/// which is not valid JS and causes the bundler's tree-shake parse to fail. -/// -/// Note: this is a Phase-1 fix that only stops the build from breaking. The -/// alias binding itself (so `{{ alias.name }}` inside the body actually -/// resolves at runtime) is tracked as a separate follow-up. +/// expression so the codegen can compile it as a plain JavaScript expression +/// for `ɵɵconditional(...)`. Pairs with [`extract_block_alias`], which +/// recovers the alias name itself. fn strip_block_alias(condition: &str) -> &str { let trimmed = condition.trim(); if let Some(idx) = find_alias_separator(trimmed) { @@ -3346,6 +3467,31 @@ fn strip_block_alias(condition: &str) -> &str { } } +/// Recover the alias name from a `@if (expr; as )` / +/// `@else if (expr; as )` control-flow condition, or return `None` +/// for plain conditions. Used together with [`strip_block_alias`] to drive +/// alias binding: the alias becomes the matching template's `_ctx` +/// parameter, and references inside the body resolve to that parameter +/// (directly in the body, via `ɵɵnextContext()` from nested scopes). +fn extract_block_alias(condition: &str) -> Option<&str> { + let trimmed = condition.trim(); + let idx = find_alias_separator(trimmed)?; + let after = trimmed[idx + 1..].trim_start(); + let rest = after.strip_prefix("as")?.trim_start(); + if rest.is_empty() { + return None; + } + let end = rest + .find(|c: char| !is_js_ident_continue(c)) + .unwrap_or(rest.len()); + let name = &rest[..end]; + if name.is_empty() { + None + } else { + Some(name) + } +} + /// Find the byte index of the `;` that introduces an `as ` clause in /// a control-flow condition, if one is present. Returns `None` for plain /// expressions that contain no alias. @@ -4033,6 +4179,23 @@ mod tests { assert_eq!(strip_block_alias("foo;ascending"), "foo;ascending"); } + #[test] + fn extract_block_alias_picks_up_name() { + assert_eq!(extract_block_alias("item(); as it"), Some("it")); + assert_eq!(extract_block_alias("state$ | async; as s"), Some("s")); + assert_eq!(extract_block_alias("a && b; as x"), Some("x")); + assert_eq!(extract_block_alias("foo;as bar"), Some("bar")); + } + + #[test] + fn extract_block_alias_returns_none_without_alias_clause() { + assert_eq!(extract_block_alias("ctx.x"), None); + assert_eq!(extract_block_alias("a && b"), None); + // Inside parens / strings — `find_alias_separator` skips these. + assert_eq!(extract_block_alias("f(a; as b)"), None); + assert_eq!(extract_block_alias("'a; as b'"), None); + } + fn test_component() -> ExtractedComponent { ExtractedComponent { class_name: "TestComponent".to_string(), @@ -5470,4 +5633,141 @@ mod tests { "no parent reference → ɵɵnextContext must not be emitted: {body}" ); } + + /// Combine the component's defineComponent block and all child template + /// functions into one searchable string — `@if` codegen lives in + /// `child_template_functions`, not `static_fields`. + fn full_emit(output: &IvyOutput) -> String { + let mut s = output.static_fields.join("\n"); + s.push('\n'); + s.push_str(&output.child_template_functions.join("\n")); + s + } + + /// `@if (expr; as alias)` must: + /// 1. bind the alias as the inner template function's `_ctx` parameter, + /// 2. leave body references to the alias unprefixed (no `ctx.`), + /// 3. pass the truthy expression value as `ɵɵconditional`'s second arg. + #[test] + fn if_block_alias_binds_to_inner_ctx_param() { + let output = compile_template("@if (item(); as it) { {{ it.name }} }"); + let dc = full_emit(&output); + assert!( + dc.contains("function TestComponent_Conditional_0_Template(rf, it)"), + "alias must rename the embedded view's _ctx parameter: {dc}" + ); + assert!( + dc.contains("\u{0275}\u{0275}textInterpolate(it.name);"), + "alias references must resolve to the param, not ctx.: {dc}" + ); + assert!( + !dc.contains("ctx.it."), + "must not fall back to ctx.. on the parent: {dc}" + ); + assert!( + dc.contains("\u{0275}\u{0275}conditional(ctx.item() ? 0 : -1, ctx.item() ? ctx.item() : null);"), + "ɵɵconditional must receive the truthy value as its second arg: {dc}" + ); + } + + /// Plain `@if` (no alias) must keep the historic single-argument + /// `ɵɵconditional(...)` emission — adding a second argument would shift + /// `_ctx` to whatever value we pass, breaking sibling templates that + /// don't expect it. + #[test] + fn if_block_without_alias_keeps_single_arg_conditional() { + let output = compile_template("@if (show) {

hi

}"); + let dc = full_emit(&output); + assert!( + dc.contains("function TestComponent_Conditional_0_Template(rf, _ctx)"), + "no-alias branch must keep `_ctx` parameter name: {dc}" + ); + assert!( + dc.contains("\u{0275}\u{0275}conditional(ctx.show ? 0 : -1);"), + "no-alias branch must emit single-arg ɵɵconditional: {dc}" + ); + } + + /// `@else if (expr; as alias)` must bind its own alias independently of + /// the `@if` branch. + #[test] + fn else_if_block_alias_binds_per_branch() { + let output = compile_template( + "@if (a; as ax) { {{ ax.foo }} } @else if (b; as bx) { {{ bx.bar }} }", + ); + let dc = full_emit(&output); + assert!( + dc.contains("function TestComponent_Conditional_0_Template(rf, ax)"), + "@if branch's alias must be its template param: {dc}" + ); + assert!( + dc.contains("function TestComponent_ConditionalElseIf_1_Template(rf, bx)"), + "@else if branch's alias must be its template param: {dc}" + ); + // The slot ternary stays in source order; the alias-value chain + // resolves each branch's compiled expression in lock-step. + assert!( + dc.contains("\u{0275}\u{0275}conditional(ctx.a ? 0 : ctx.b ? 1 : -1, ctx.a ? ctx.a : ctx.b ? ctx.b : null);"), + "alias-value chain must match the slot chain branch-for-branch: {dc}" + ); + } + + /// References to the alias from a nested template (e.g. `@switch` inside + /// the `@if` body) must walk back via `ɵɵnextContext()` — the @if's + /// embedded view holds the alias as its `_ctx`, and nested scopes + /// don't have the alias as their own function parameter. + #[test] + fn if_block_alias_reaches_nested_scopes_via_next_context() { + let output = compile_template( + "@if (state(); as s) { @switch (s.k) { @case ('a') { {{ s.v }} } } }", + ); + let dc = full_emit(&output); + // The @switch case's template binds `s` from the @if's embedded view. + assert!( + dc.contains("const s = \u{0275}\u{0275}nextContext();"), + "nested case must extract the outer alias via nextContext(): {dc}" + ); + assert!( + dc.contains("\u{0275}\u{0275}textInterpolate(s.v);"), + "alias references in nested scopes must stay unprefixed: {dc}" + ); + assert!( + !dc.contains("ctx.s.") && !dc.contains("ctx.s "), + "must not fall back to ctx.. from nested scopes: {dc}" + ); + } + + /// Pipes inside an `@if` condition (e.g. `state$ | async; as s`) must + /// register `ɵɵpipe(...)` at the parent template and reuse the same + /// `ɵɵpipeBind1(...)` form for both `ɵɵconditional` arguments, so the + /// alias receives the resolved (subscribed) value — not the raw + /// observable. + #[test] + fn if_block_alias_compiles_pipe_in_condition() { + let output = compile_template("@if (state$ | async; as s) { {{ s.value }} }"); + let dc = full_emit(&output); + assert!( + output.ivy_imports.contains("\u{0275}\u{0275}pipeBind1"), + "async pipe in @if condition must register pipeBind1: imports={:?}", + output.ivy_imports + ); + assert!( + dc.contains("\u{0275}\u{0275}pipe(") && dc.contains(", 'async')"), + "async pipe in @if condition must register a pipe slot: {dc}" + ); + assert!( + dc.contains("function TestComponent_Conditional_0_Template(rf, s)"), + "alias `s` must be the inner template param: {dc}" + ); + // Both ɵɵconditional args reuse the pipeBind1 form so the alias and + // the slot decision see the same resolved value. + let conditional_call = dc + .lines() + .find(|l| l.contains("\u{0275}\u{0275}conditional(")) + .expect("ɵɵconditional call must be emitted"); + assert!( + conditional_call.matches("\u{0275}\u{0275}pipeBind1").count() >= 3, + "ɵɵconditional must reuse pipeBind1 across test and alias-value chains: {conditional_call}" + ); + } } diff --git a/crates/template-compiler/tests/if_alias_binding_integration.rs b/crates/template-compiler/tests/if_alias_binding_integration.rs new file mode 100644 index 0000000..c158c3e --- /dev/null +++ b/crates/template-compiler/tests/if_alias_binding_integration.rs @@ -0,0 +1,195 @@ +//! End-to-end integration test for `@if (expr; as alias)` / `@else if (...)` +//! alias binding (issue #166). +//! +//! `#165` stopped the build from breaking on the `; as alias` syntax but the +//! alias was not actually bound at runtime — references inside the body +//! compiled to `ctx.` on the parent component context, which is +//! `undefined` for any component that doesn't happen to carry such a field. +//! +//! This file pins the corrected codegen end-to-end: a component whose +//! template uses `@if (item(); as it) { {{ it.name }} ... }` (the +//! `DetailComponent` pattern) plus `@else if (...; as ...)` and a nested +//! `@switch` inside an aliased `@if` (the `HttpClientComponent` pattern) +//! must run through `compile_component` and emit code that: +//! +//! * passes the truthy expression value as `ɵɵconditional`'s second arg, +//! * uses the alias as the inner template function's parameter, and +//! * resolves alias references from nested scopes via `ɵɵnextContext()`. + +use std::path::PathBuf; + +use ngc_template_compiler::compile_component; + +const DETAIL_FIXTURE: &str = r#" +import { Component, input } from '@angular/core'; + +interface Item { + id: string; + name: string; + summary: string; +} + +@Component({ + selector: 'app-routing-detail', + standalone: true, + template: ` + @if (item(); as it) { +

{{ it.name }}

+

{{ it.summary }}

+ } @else { +

Item not found.

+ } + `, +}) +export class DetailComponent { + readonly item = input(null); +} +"#; + +const ELSE_IF_FIXTURE: &str = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-x', + standalone: true, + template: ` + @if (a(); as ax) { {{ ax.foo }} } + @else if (b(); as bx) { {{ bx.bar }} } + `, +}) +export class XComponent { + a() { return null; } + b() { return null; } +} +"#; + +const NESTED_FIXTURE: &str = r#" +import { Component } from '@angular/core'; + +interface State { k: string; v: string; } + +@Component({ + selector: 'app-y', + standalone: true, + template: ` + @if (state(); as s) { + @switch (s.k) { + @case ('a') {

{{ s.v }}

} + } + } + `, +}) +export class YComponent { + state(): State | null { return null; } +} +"#; + +#[test] +fn if_alias_emits_runtime_correct_codegen() { + let compiled = + compile_component(DETAIL_FIXTURE, &PathBuf::from("detail.component.ts")) + .expect("component should compile"); + + assert!( + compiled.compiled, + "compile_component must rewrite the @if-alias source" + ); + assert!( + !compiled.jit_fallback, + "an `@if (expr; as alias)` body must not trigger JIT fallback" + ); + + let out = &compiled.source; + + // 1. The aliased branch's child template takes the alias as its `_ctx` + // parameter (`it`) — that is the runtime value Angular's + // `ɵɵconditional` delivers. + assert!( + out.contains("function DetailComponent_Conditional_0_Template(rf, it)"), + "alias must be the inner template's _ctx parameter:\n{out}" + ); + + // 2. Body interpolations read the parameter directly, NOT `ctx.it` on + // the parent (which has no `it` field). + assert!( + out.contains("\u{0275}\u{0275}textInterpolate(it.name);"), + "alias body must reference the alias as a local, not ctx.:\n{out}" + ); + assert!( + !out.contains("ctx.it."), + "alias body must NOT fall back to ctx..:\n{out}" + ); + + // 3. The `ɵɵconditional` call passes the truthy expression value as its + // second arg so the matching template's `_ctx` carries it. The + // expression is re-evaluated (same shape Angular's own compiler + // emits) — `item()` appears on both sides of the ternary chain. + assert!( + out.contains( + "\u{0275}\u{0275}conditional(ctx.item() ? " + ) && out.contains(", ctx.item() ? ctx.item() : null);"), + "ɵɵconditional must receive the alias value as its second arg:\n{out}" + ); + + // 4. The `@else` branch keeps the generic `_ctx` parameter (Angular's + // grammar does not allow `; as alias` on `@else`). + assert!( + out.contains("function DetailComponent_ConditionalElse_1_Template(rf, _ctx)"), + "@else branch must keep _ctx param when there is no alias:\n{out}" + ); +} + +#[test] +fn else_if_alias_binds_per_branch_independently() { + let compiled = + compile_component(ELSE_IF_FIXTURE, &PathBuf::from("x.component.ts")) + .expect("component should compile"); + let out = &compiled.source; + + assert!( + out.contains("function XComponent_Conditional_0_Template(rf, ax)"), + "@if branch's alias must be its template param:\n{out}" + ); + assert!( + out.contains("function XComponent_ConditionalElseIf_1_Template(rf, bx)"), + "@else if branch's alias must be its template param:\n{out}" + ); + // Both branches contribute to the alias-value chain in the same order + // as the test chain — when branch N matches, the matching template's + // _ctx is the N-th branch's expression value. + assert!( + out.contains( + "\u{0275}\u{0275}conditional(ctx.a() ? 0 : ctx.b() ? 1 : -1, \ + ctx.a() ? ctx.a() : ctx.b() ? ctx.b() : null);" + ), + "alias-value chain must mirror the test chain branch-for-branch:\n{out}" + ); +} + +#[test] +fn nested_scope_reads_outer_if_alias_via_next_context() { + let compiled = + compile_component(NESTED_FIXTURE, &PathBuf::from("y.component.ts")) + .expect("component should compile"); + let out = &compiled.source; + + // The @switch case body is nested two levels deep (root → @if → @switch + // case). The @if's embedded view holds the alias `s` as its `_ctx`, so + // a single `ɵɵnextContext()` from the case body retrieves it; the + // navigation prelude must emit that binding before the body's + // instructions. + assert!( + out.contains("const s = \u{0275}\u{0275}nextContext();"), + "nested case must extract the outer alias via ɵɵnextContext():\n{out}" + ); + // Interpolation inside the @switch case reads the local `s`, never + // `ctx.s` on the component. + assert!( + out.contains("\u{0275}\u{0275}textInterpolate(s.v);"), + "nested alias references stay unprefixed:\n{out}" + ); + assert!( + !out.contains("ctx.s."), + "nested scope must NOT read ctx..:\n{out}" + ); +} From b2e1bc6cbc29a8786833ccff83e684ce7a038b50 Mon Sep 17 00:00:00 2001 From: lukekania Date: Mon, 18 May 2026 11:35:36 +0200 Subject: [PATCH 02/20] chore: bump version to 0.10.9 --- Cargo.lock | 20 ++++++++++---------- Cargo.toml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0fc1f21..e6b09e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,7 +730,7 @@ dependencies = [ [[package]] name = "ngc-bundler" -version = "0.10.8" +version = "0.10.9" dependencies = [ "dashmap", "ngc-diagnostics", @@ -755,7 +755,7 @@ dependencies = [ [[package]] name = "ngc-dev-server" -version = "0.10.8" +version = "0.10.9" dependencies = [ "ngc-diagnostics", "serde_json", @@ -766,7 +766,7 @@ dependencies = [ [[package]] name = "ngc-diagnostics" -version = "0.10.8" +version = "0.10.9" dependencies = [ "serde_json", "thiserror", @@ -774,7 +774,7 @@ dependencies = [ [[package]] name = "ngc-linker" -version = "0.10.8" +version = "0.10.9" dependencies = [ "dashmap", "insta", @@ -792,7 +792,7 @@ dependencies = [ [[package]] name = "ngc-npm-resolver" -version = "0.10.8" +version = "0.10.9" dependencies = [ "dashmap", "ngc-diagnostics", @@ -807,7 +807,7 @@ dependencies = [ [[package]] name = "ngc-project-resolver" -version = "0.10.8" +version = "0.10.9" dependencies = [ "dashmap", "glob", @@ -823,7 +823,7 @@ dependencies = [ [[package]] name = "ngc-rs" -version = "0.10.8" +version = "0.10.9" dependencies = [ "base64", "clap", @@ -857,7 +857,7 @@ dependencies = [ [[package]] name = "ngc-template-compiler" -version = "0.10.8" +version = "0.10.9" dependencies = [ "insta", "ngc-diagnostics", @@ -879,7 +879,7 @@ dependencies = [ [[package]] name = "ngc-ts-transform" -version = "0.10.8" +version = "0.10.9" dependencies = [ "ngc-diagnostics", "oxc_allocator", @@ -898,7 +898,7 @@ dependencies = [ [[package]] name = "ngc-watch" -version = "0.10.8" +version = "0.10.9" dependencies = [ "ngc-diagnostics", "notify", diff --git a/Cargo.toml b/Cargo.toml index c7006c5..8c90479 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.8" +version = "0.10.9" edition = "2021" license = "MIT OR Apache-2.0" authors = ["lukekania"] From 68a6c85e76ade451459779d2747e272f290d145f Mon Sep 17 00:00:00 2001 From: lukekania Date: Mon, 18 May 2026 10:28:44 +0200 Subject: [PATCH 03/20] feat(bundler): honor angular.json `externalDependencies` (#146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for `externalDependencies` in `angular.json` so projects loading specific npm packages from a CDN (or expecting them as `import` map entries) can exclude those packages from the bundle — matching `@angular/build:application`. - project-resolver: parse `externalDependencies` (base + per-config override) into `ResolvedAngularProject.external_dependencies`. - npm-resolver: new `resolve_npm_dependencies_with_externals` skips externalised specifiers in phase 1 and the transitive BFS, so a package and all its modules stay out of `npm_resolution`. - bundler: `BundleInput.external_specifiers` vetoes `is_local` — an import whose specifier matches an external entry (exact or `/...` subpath) is emitted verbatim and never rewritten to a `__ns_*` namespace reference. Lazy-chunk routing keeps externals as bare specifiers instead of folding them through `./main.js`. - cli: thread externals through every npm-resolver call site; strip externals from `bundled_specifiers` defensively before bundling. - builder: drop the stale "currently ignored" warning. Verified against `test-ng-project`: declaring `externalDependencies: ["jquery"]` and importing `$ from 'jquery'` (and the subpath `jquery/dist/jquery.slim`) emits the imports verbatim in `main.js`, jquery is not installed in the fixture so unresolved externals are silently skipped by the resolver as intended. Bumps workspace to 0.10.10. --- Cargo.lock | 20 +-- Cargo.toml | 2 +- crates/bundler/src/concat.rs | 129 +++++++++++++++++- crates/bundler/src/rewrite.rs | 40 +++++- crates/bundler/tests/defer_integration.rs | 1 + .../tests/subpath_imports_integration.rs | 4 + .../vendor_chunk_splitting_integration.rs | 1 + crates/bundler/tests/worker_integration.rs | 3 + crates/cli/src/main.rs | 46 ++++++- crates/cli/src/polyfills.rs | 1 + crates/npm-resolver/src/lib.rs | 100 ++++++++++++++ crates/project-resolver/src/angular_json.rs | 100 ++++++++++++++ packages/builder/src/build/options.ts | 5 - 13 files changed, 427 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6b09e0..c83a18c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,7 +730,7 @@ dependencies = [ [[package]] name = "ngc-bundler" -version = "0.10.9" +version = "0.10.10" dependencies = [ "dashmap", "ngc-diagnostics", @@ -755,7 +755,7 @@ dependencies = [ [[package]] name = "ngc-dev-server" -version = "0.10.9" +version = "0.10.10" dependencies = [ "ngc-diagnostics", "serde_json", @@ -766,7 +766,7 @@ dependencies = [ [[package]] name = "ngc-diagnostics" -version = "0.10.9" +version = "0.10.10" dependencies = [ "serde_json", "thiserror", @@ -774,7 +774,7 @@ dependencies = [ [[package]] name = "ngc-linker" -version = "0.10.9" +version = "0.10.10" dependencies = [ "dashmap", "insta", @@ -792,7 +792,7 @@ dependencies = [ [[package]] name = "ngc-npm-resolver" -version = "0.10.9" +version = "0.10.10" dependencies = [ "dashmap", "ngc-diagnostics", @@ -807,7 +807,7 @@ dependencies = [ [[package]] name = "ngc-project-resolver" -version = "0.10.9" +version = "0.10.10" dependencies = [ "dashmap", "glob", @@ -823,7 +823,7 @@ dependencies = [ [[package]] name = "ngc-rs" -version = "0.10.9" +version = "0.10.10" dependencies = [ "base64", "clap", @@ -857,7 +857,7 @@ dependencies = [ [[package]] name = "ngc-template-compiler" -version = "0.10.9" +version = "0.10.10" dependencies = [ "insta", "ngc-diagnostics", @@ -879,7 +879,7 @@ dependencies = [ [[package]] name = "ngc-ts-transform" -version = "0.10.9" +version = "0.10.10" dependencies = [ "ngc-diagnostics", "oxc_allocator", @@ -898,7 +898,7 @@ dependencies = [ [[package]] name = "ngc-watch" -version = "0.10.9" +version = "0.10.10" dependencies = [ "ngc-diagnostics", "notify", diff --git a/Cargo.toml b/Cargo.toml index 8c90479..cadb00f 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.9" +version = "0.10.10" edition = "2021" license = "MIT OR Apache-2.0" authors = ["lukekania"] diff --git a/crates/bundler/src/concat.rs b/crates/bundler/src/concat.rs index bf6ac5d..fc96c57 100644 --- a/crates/bundler/src/concat.rs +++ b/crates/bundler/src/concat.rs @@ -66,6 +66,15 @@ pub struct BundleInput { /// Bare specifiers that have been resolved and included in the graph. /// The rewriter treats imports of these specifiers as local (strips them). pub bundled_specifiers: HashSet, + /// Bare specifiers declared as external (`externalDependencies` in + /// `angular.json`). Imports of these specifiers — or of subpaths of + /// them, e.g. `jquery/dist/jquery.slim` — stay as bare ESM specifiers + /// in the emitted bundle, with no namespace rewrite and no inlining. + /// This list is enforced *in addition to* the absence of an entry in + /// `bundled_specifiers`: if a specifier appears in `external_specifiers`, + /// it is always treated as external, even if some upstream pass leaked + /// it into `bundled_specifiers`. + pub external_specifiers: HashSet, /// Active `exports` conditions (e.g. `browser`, `import`, `production`). /// Forwarded to the npm resolver when re-resolving specifiers during /// bundling so the same branch of conditional exports selected during @@ -215,6 +224,7 @@ pub fn bundle(input: &BundleInput) -> NgcResult { generate_source_maps: input.options.source_maps, unused_exports: &unused_exports, bundled_specifiers: &input.bundled_specifiers, + external_specifiers: &input.external_specifiers, chunk_entry: &chunk.entry, chunk_kind: &chunk.kind, chunk_module_set: &chunk_module_set, @@ -537,6 +547,9 @@ struct ChunkBundleParams<'a> { generate_source_maps: bool, unused_exports: &'a HashMap>, bundled_specifiers: &'a HashSet, + /// Specifiers declared external via `externalDependencies` in + /// `angular.json`. Imports matching these stay as bare specifiers. + external_specifiers: &'a HashSet, /// The chunk's entry module — exports from this module are preserved. chunk_entry: &'a Path, /// The kind of chunk being bundled (Main, Lazy, or Shared). @@ -707,6 +720,7 @@ fn bundle_chunk(p: &ChunkBundleParams<'_>) -> NgcResult { module_unused, effective_bundled, effective_ns_map, + p.external_specifiers, is_chunk_entry, )?; @@ -743,8 +757,16 @@ fn bundle_chunk(p: &ChunkBundleParams<'_>) -> NgcResult { if is_lazy { let mut main_js_named = BTreeSet::new(); let mut main_js_default = None; + // Externalised imports survive intact — they are bare specifiers the + // runtime resolves (import map, CDN). They are NOT re-exported from + // main, NOT routed cross-chunk, and NOT folded into `npm_externals`. + let mut keep_as_bare: Vec = Vec::new(); for ext in all_externals { + if rewrite::matches_external_specifier(&ext.source, p.external_specifiers) { + keep_as_bare.push(ext); + continue; + } let is_from_npm = ext.source.starts_with("__resolved_ns__") || ext.source.starts_with("__npm_") || p.bundled_specifiers.contains(&ext.source) @@ -775,7 +797,7 @@ fn bundle_chunk(p: &ChunkBundleParams<'_>) -> NgcResult { } } - all_externals = Vec::new(); + all_externals = keep_as_bare; if let Some(default_name) = main_js_default { main_js_named.insert(default_name); @@ -891,6 +913,14 @@ fn classify_lazy_externals( let mut out: Vec = Vec::with_capacity(externals.len()); for ext in externals { + // Externalised dependencies (`externalDependencies` in angular.json) + // stay as bare specifiers — they neither resolve to a chunk module + // nor get routed through `./main.js`, so pass them through verbatim + // and let the lazy-chunk routing block keep them in `keep_as_bare`. + if rewrite::matches_external_specifier(&ext.source, p.external_specifiers) { + out.push(ext); + continue; + } // Subpath imports (`#foo`) point at a file under the importing // package's `imports` map — typically a project file, occasionally // a bare specifier. Resolve to the actual target so chunk-membership @@ -1426,6 +1456,7 @@ mod tests { options: BundleOptions::default(), per_module_maps: HashMap::new(), bundled_specifiers: HashSet::new(), + external_specifiers: HashSet::new(), export_conditions: Vec::new(), }; @@ -1473,6 +1504,7 @@ mod tests { options: BundleOptions::default(), per_module_maps: HashMap::new(), bundled_specifiers: HashSet::new(), + external_specifiers: HashSet::new(), export_conditions: Vec::new(), }; @@ -1485,6 +1517,95 @@ mod tests { assert!(result.contains("Injectable")); } + #[test] + fn test_external_specifier_kept_as_bare_import_even_if_bundled() { + // Regression for issue #146: when a specifier appears in + // `external_specifiers`, the rewriter must leave the import as a + // bare ESM import, even if `bundled_specifiers` happens to contain + // the same string (the negative set vetoes the positive one). + let mut graph = DiGraph::new(); + let entry = graph.add_node(make_path("/root/main.ts")); + let _ = entry; + + let mut modules = HashMap::new(); + modules.insert( + make_path("/root/main.ts"), + "import $ from 'jquery';\nimport { trim } from 'jquery';\nconsole.log($, trim);\n" + .to_string(), + ); + + let mut bundled = HashSet::new(); + bundled.insert("jquery".to_string()); // simulate it leaking in + let mut externals = HashSet::new(); + externals.insert("jquery".to_string()); + + let input = BundleInput { + modules, + graph, + entry: make_path("/root/main.ts"), + local_prefixes: vec![".".to_string()], + root_dir: make_path("/root"), + options: BundleOptions::default(), + per_module_maps: HashMap::new(), + bundled_specifiers: bundled, + external_specifiers: externals, + export_conditions: Vec::new(), + }; + + let output = bundle(&input).expect("should bundle"); + let result = main_chunk(&output); + assert!( + result.contains("from 'jquery'"), + "external jquery import should survive verbatim, got:\n{result}" + ); + // Verify it's a hoisted ESM import — not rewritten into a + // `var $ = __ns_jquery.default` namespace assignment. + assert!( + !result.contains("__ns_jquery"), + "external import must not be rewritten to namespace; got:\n{result}" + ); + } + + #[test] + fn test_external_specifier_subpath_kept_as_bare_import() { + // `externalDependencies: ["jquery"]` should also externalise + // subpath imports like `jquery/dist/jquery.slim`, matching how + // esbuild's `--external: jquery` behaves under + // `@angular/build:application`. + let mut graph = DiGraph::new(); + let entry = graph.add_node(make_path("/root/main.ts")); + let _ = entry; + + let mut modules = HashMap::new(); + modules.insert( + make_path("/root/main.ts"), + "import $ from 'jquery/dist/jquery.slim';\nconsole.log($);\n".to_string(), + ); + + let mut externals = HashSet::new(); + externals.insert("jquery".to_string()); + + let input = BundleInput { + modules, + graph, + entry: make_path("/root/main.ts"), + local_prefixes: vec![".".to_string()], + root_dir: make_path("/root"), + options: BundleOptions::default(), + per_module_maps: HashMap::new(), + bundled_specifiers: HashSet::new(), + external_specifiers: externals, + export_conditions: Vec::new(), + }; + + let output = bundle(&input).expect("should bundle"); + let result = main_chunk(&output); + assert!( + result.contains("from 'jquery/dist/jquery.slim'"), + "subpath of external package should survive verbatim, got:\n{result}" + ); + } + #[test] fn test_unreachable_module_excluded() { let mut graph = DiGraph::new(); @@ -1516,6 +1637,7 @@ mod tests { options: BundleOptions::default(), per_module_maps: HashMap::new(), bundled_specifiers: HashSet::new(), + external_specifiers: HashSet::new(), export_conditions: Vec::new(), }; @@ -1560,6 +1682,7 @@ mod tests { options: BundleOptions::default(), per_module_maps: HashMap::new(), bundled_specifiers: HashSet::new(), + external_specifiers: HashSet::new(), export_conditions: Vec::new(), }; @@ -1632,6 +1755,7 @@ mod tests { options: BundleOptions::default(), per_module_maps: HashMap::new(), bundled_specifiers: HashSet::new(), + external_specifiers: HashSet::new(), export_conditions: Vec::new(), }; @@ -1766,6 +1890,7 @@ mod tests { }, per_module_maps, bundled_specifiers: HashSet::new(), + external_specifiers: HashSet::new(), export_conditions: Vec::new(), }; @@ -1816,6 +1941,7 @@ mod tests { options: BundleOptions::default(), per_module_maps: HashMap::new(), bundled_specifiers: HashSet::new(), + external_specifiers: HashSet::new(), export_conditions: Vec::new(), }; @@ -1874,6 +2000,7 @@ mod tests { }, per_module_maps: HashMap::new(), bundled_specifiers: HashSet::new(), + external_specifiers: HashSet::new(), export_conditions: Vec::new(), }; diff --git a/crates/bundler/src/rewrite.rs b/crates/bundler/src/rewrite.rs index ae918f1..15dde5c 100644 --- a/crates/bundler/src/rewrite.rs +++ b/crates/bundler/src/rewrite.rs @@ -66,6 +66,7 @@ pub fn rewrite_module( None, &HashSet::new(), &HashMap::new(), + &HashSet::new(), false, ) } @@ -83,6 +84,7 @@ pub fn rewrite_module_with_shaking( unused_exports: Option<&HashSet>, bundled_specifiers: &HashSet, namespace_map: &HashMap, + external_specifiers: &HashSet, preserve_exports: bool, ) -> NgcResult { let allocator = Allocator::new(); @@ -116,6 +118,7 @@ pub fn rewrite_module_with_shaking( unused_exports, bundled_specifiers, namespace_map, + external_specifiers, preserve_exports, ) } else { @@ -158,12 +161,13 @@ fn collect_module_decl_edits( unused_exports: Option<&HashSet>, bundled_specifiers: &HashSet, namespace_map: &HashMap, + external_specifiers: &HashSet, preserve_exports: bool, ) -> bool { match module_decl { ModuleDeclaration::ImportDeclaration(import) => { let source = import.source.value.as_str(); - if is_local(source, local_prefixes, bundled_specifiers) { + if is_local(source, local_prefixes, bundled_specifiers, external_specifiers) { // Check if this import has a namespace mapping (npm module) if let Some(ns) = namespace_map.get(source) { // Replace import with namespace lookups @@ -326,6 +330,7 @@ fn collect_module_decl_edits( export.source.value.as_str(), local_prefixes, bundled_specifiers, + external_specifiers, ) => { edits.push(TextEdit { @@ -774,17 +779,46 @@ fn get_declaration_name(decl: &oxc_ast::ast::Declaration) -> Option { } /// Check if an import specifier is local based on known prefixes or bundled specifiers. +/// +/// `external_specifiers` is a *veto* set — when a specifier matches one of +/// its entries (by exact name or `/...` subpath), it is treated as +/// external no matter what else would classify it. This is how +/// `angular.json`'s `externalDependencies` keeps imports like +/// `import $ from 'jquery'` from being inlined. fn is_local( specifier: &str, local_prefixes: &[&str], bundled_specifiers: &HashSet, + external_specifiers: &HashSet, ) -> bool { + if matches_external_specifier(specifier, external_specifiers) { + return false; + } local_prefixes .iter() .any(|prefix| specifier.starts_with(prefix)) || bundled_specifiers.contains(specifier) } +/// Returns true when `specifier` is either an exact entry in +/// `external_specifiers` or a subpath of one (e.g. `jquery/dist/jquery.slim` +/// when `external_specifiers` lists `jquery`). Mirrors esbuild's `--external` +/// matching, which is what `@angular/build:application` uses under the hood. +pub(crate) fn matches_external_specifier( + specifier: &str, + external_specifiers: &HashSet, +) -> bool { + if external_specifiers.is_empty() { + return false; + } + if external_specifiers.contains(specifier) { + return true; + } + external_specifiers + .iter() + .any(|ext| specifier.starts_with(ext) && specifier[ext.len()..].starts_with('/')) +} + /// Apply text edits to the source, producing the rewritten code. fn apply_edits(source: &str, edits: &mut [TextEdit]) -> String { // Sort in reverse order so later edits don't shift earlier offsets @@ -1094,6 +1128,7 @@ mod tests { Some(&unused), &HashSet::new(), &HashMap::new(), + &HashSet::new(), false, ) .expect("should rewrite"); @@ -1128,6 +1163,7 @@ mod tests { Some(&empty_unused), &HashSet::new(), &HashMap::new(), + &HashSet::new(), false, ) .expect("should rewrite"); @@ -1155,6 +1191,7 @@ mod tests { Some(&unused), &HashSet::new(), &HashMap::new(), + &HashSet::new(), false, ) .expect("should rewrite"); @@ -1185,6 +1222,7 @@ mod tests { Some(&empty_unused), &HashSet::new(), &HashMap::new(), + &HashSet::new(), false, ) .expect("should rewrite"); diff --git a/crates/bundler/tests/defer_integration.rs b/crates/bundler/tests/defer_integration.rs index d55c8d1..c71e752 100644 --- a/crates/bundler/tests/defer_integration.rs +++ b/crates/bundler/tests/defer_integration.rs @@ -116,6 +116,7 @@ fn defer_deferred_component_is_chunk_split_placeholder_stays_in_main() { options: BundleOptions::default(), per_module_maps: HashMap::new(), bundled_specifiers: Default::default(), + external_specifiers: Default::default(), export_conditions: Vec::new(), }; diff --git a/crates/bundler/tests/subpath_imports_integration.rs b/crates/bundler/tests/subpath_imports_integration.rs index 7fa66ad..fb1570f 100644 --- a/crates/bundler/tests/subpath_imports_integration.rs +++ b/crates/bundler/tests/subpath_imports_integration.rs @@ -134,6 +134,7 @@ fn subpath_import_helper_is_inlined_into_main_chunk() { options: BundleOptions::default(), per_module_maps: HashMap::new(), bundled_specifiers: npm.resolved_specifiers.clone(), + external_specifiers: Default::default(), export_conditions: Vec::new(), }; @@ -270,6 +271,7 @@ fn subpath_import_in_lazy_chunk_does_not_leak_to_main_import() { options: BundleOptions::default(), per_module_maps: HashMap::new(), bundled_specifiers: npm.resolved_specifiers.clone(), + external_specifiers: Default::default(), export_conditions: Vec::new(), }; @@ -421,6 +423,7 @@ fn subpath_import_const_referenced_from_class_field_survives_tree_shake() { }, per_module_maps: HashMap::new(), bundled_specifiers: npm.resolved_specifiers.clone(), + external_specifiers: Default::default(), export_conditions: Vec::new(), }; @@ -526,6 +529,7 @@ fn relative_import_const_referenced_from_class_field_survives_tree_shake() { }, per_module_maps: HashMap::new(), bundled_specifiers: npm.resolved_specifiers.clone(), + external_specifiers: Default::default(), export_conditions: Vec::new(), }; diff --git a/crates/bundler/tests/vendor_chunk_splitting_integration.rs b/crates/bundler/tests/vendor_chunk_splitting_integration.rs index 9efca34..cf1ab6d 100644 --- a/crates/bundler/tests/vendor_chunk_splitting_integration.rs +++ b/crates/bundler/tests/vendor_chunk_splitting_integration.rs @@ -135,6 +135,7 @@ fn build_two_lazy_routes_sharing_npm(root: &std::path::Path) -> BundleInput { options: BundleOptions::default(), per_module_maps: HashMap::new(), bundled_specifiers: npm.resolved_specifiers.clone(), + external_specifiers: Default::default(), export_conditions: Vec::new(), } } diff --git a/crates/bundler/tests/worker_integration.rs b/crates/bundler/tests/worker_integration.rs index f3c98a7..9c57fb6 100644 --- a/crates/bundler/tests/worker_integration.rs +++ b/crates/bundler/tests/worker_integration.rs @@ -81,6 +81,7 @@ fn worker_new_url_is_bundled_as_separate_chunk_and_rewritten() { options: BundleOptions::default(), per_module_maps: HashMap::new(), bundled_specifiers: Default::default(), + external_specifiers: Default::default(), export_conditions: Vec::new(), }; @@ -207,6 +208,7 @@ fn worker_url_in_class_constructor_under_web_worker_dir_is_rewritten() { options: BundleOptions::default(), per_module_maps: HashMap::new(), bundled_specifiers: Default::default(), + external_specifiers: Default::default(), export_conditions: Vec::new(), }; @@ -301,6 +303,7 @@ fn worker_url_rewrite_uses_content_hashed_filename() { }, per_module_maps: HashMap::new(), bundled_specifiers: Default::default(), + external_specifiers: Default::default(), export_conditions: Vec::new(), }; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index d497255..5aa980a 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -687,19 +687,40 @@ pub(crate) fn run_build_with_options( // Collect bare specifiers from project scanning AND from transformed output // (oxc may inject new imports like @oxc-project/runtime/helpers/decorate) let npm_span = tracing::info_span!("npm_resolve").entered(); - let mut bare_specifiers: Vec = file_graph.npm_import_sites.keys().cloned().collect(); + // `externalDependencies` from angular.json — these are NOT bundled + // (their imports stay as bare ESM specifiers for the runtime to + // resolve via an import map / CDN). Build the set once and use it + // to filter every list we hand to `resolve_npm_dependencies` so the + // BFS never walks into an externalised package's modules. + let external_specifiers: std::collections::HashSet = angular_project + .as_ref() + .map(|ap| ap.external_dependencies.iter().cloned().collect()) + .unwrap_or_default(); + let is_external = |spec: &str| -> bool { + external_specifiers.contains(spec) + || external_specifiers.iter().any(|ext| { + spec.starts_with(ext.as_str()) && spec[ext.len()..].starts_with('/') + }) + }; + let mut bare_specifiers: Vec = file_graph + .npm_import_sites + .keys() + .filter(|s| !is_external(s)) + .cloned() + .collect(); let post_transform_specifiers = scan_transformed_bare_specifiers(&modules, &local_prefixes); for spec in post_transform_specifiers { - if !bare_specifiers.contains(&spec) { + if !bare_specifiers.contains(&spec) && !is_external(&spec) { bare_specifiers.push(spec); } } let export_conditions = ngc_npm_resolver::package_json::conditions_for_configuration(configuration); - let mut npm_resolution = ngc_npm_resolver::resolve_npm_dependencies( + let mut npm_resolution = ngc_npm_resolver::resolve_npm_dependencies_with_externals( &bare_specifiers, &config_dir, export_conditions, + &external_specifiers, )?; // Merge npm modules into the modules map (they're already JS — no transform needed) @@ -759,7 +780,7 @@ pub(crate) fn run_build_with_options( let prescan_new: Vec = if public_exports.has_specifier_outside(&bare_set) { ngc_linker::flatten::scan_introduced_specifiers(&modules, ®istry, &public_exports) .into_iter() - .filter(|s| !bare_set.contains(s)) + .filter(|s| !bare_set.contains(s) && !is_external(s)) .collect() } else { Vec::new() @@ -771,10 +792,11 @@ pub(crate) fn run_build_with_options( prescan_new ); bare_specifiers.extend(prescan_new.iter().cloned()); - let extra = ngc_npm_resolver::resolve_npm_dependencies( + let extra = ngc_npm_resolver::resolve_npm_dependencies_with_externals( &prescan_new, &config_dir, export_conditions, + &external_specifiers, )?; tracing::info!( "pre-scan: pulled in {} additional file(s) before flatten", @@ -854,7 +876,7 @@ pub(crate) fn run_build_with_options( let post_link_specifiers = scan_transformed_bare_specifiers(&project_modules, &local_prefixes); let mut new_specifiers: Vec = Vec::new(); for spec in post_link_specifiers { - if !bare_specifiers.contains(&spec) { + if !bare_specifiers.contains(&spec) && !is_external(&spec) { new_specifiers.push(spec); } } @@ -865,10 +887,11 @@ pub(crate) fn run_build_with_options( new_specifiers ); bare_specifiers.extend(new_specifiers.iter().cloned()); - let extra = ngc_npm_resolver::resolve_npm_dependencies( + let extra = ngc_npm_resolver::resolve_npm_dependencies_with_externals( &new_specifiers, &config_dir, export_conditions, + &external_specifiers, )?; tracing::info!( "post-flatten npm resolution pulled in {} file(s)", @@ -1011,6 +1034,14 @@ pub(crate) fn run_build_with_options( drop(define_span); } + // Belt-and-braces: even though the resolver was told to skip externals, + // strip them from `bundled_specifiers` so the rewriter never sees an + // externalised name in its "local" set. This also handles the edge + // case where `inject_oxc_runtime_helpers` adds a specifier later — if + // somehow an external name showed up, this last filter keeps the + // bundle output correct. + bundled_specifiers.retain(|s| !external_specifiers.contains(s)); + let bundle_input = BundleInput { modules, graph, @@ -1020,6 +1051,7 @@ pub(crate) fn run_build_with_options( options: bundle_options, per_module_maps, bundled_specifiers, + external_specifiers, export_conditions: export_conditions.iter().map(|s| (*s).to_string()).collect(), }; drop(graph_span); diff --git a/crates/cli/src/polyfills.rs b/crates/cli/src/polyfills.rs index 2eab67d..7be5a42 100644 --- a/crates/cli/src/polyfills.rs +++ b/crates/cli/src/polyfills.rs @@ -264,6 +264,7 @@ pub fn generate_polyfills( options: polyfill_bundle_options, per_module_maps, bundled_specifiers, + external_specifiers: Default::default(), export_conditions: export_conditions.iter().map(|s| (*s).to_string()).collect(), }; diff --git a/crates/npm-resolver/src/lib.rs b/crates/npm-resolver/src/lib.rs index 7067d57..9eee6d1 100644 --- a/crates/npm-resolver/src/lib.rs +++ b/crates/npm-resolver/src/lib.rs @@ -43,6 +43,36 @@ pub fn resolve_npm_dependencies( specifiers: &[String], project_root: &Path, conditions: &[&str], +) -> NgcResult { + resolve_npm_dependencies_with_externals(specifiers, project_root, conditions, &HashSet::new()) +} + +/// Returns `true` when `specifier` matches one of the externalised package +/// names — either exactly (`jquery` matches `jquery`) or as a subpath +/// (`jquery/dist/slim` matches `jquery`). Mirrors esbuild's `--external` +/// matching used by `@angular/build:application`. +fn is_external_specifier(specifier: &str, externals: &HashSet) -> bool { + if externals.is_empty() { + return false; + } + if externals.contains(specifier) { + return true; + } + externals + .iter() + .any(|ext| specifier.starts_with(ext) && specifier[ext.len()..].starts_with('/')) +} + +/// Variant of [`resolve_npm_dependencies`] that skips any specifier whose +/// package name appears in `externals`. The BFS does not walk into those +/// packages, so their modules never enter the bundle — they stay as bare +/// runtime imports for the host (browser import map, CDN loader) to +/// resolve. Used to honour `angular.json`'s `externalDependencies`. +pub fn resolve_npm_dependencies_with_externals( + specifiers: &[String], + project_root: &Path, + conditions: &[&str], + externals: &HashSet, ) -> NgcResult { let node_modules = project_root.join("node_modules"); if !node_modules.is_dir() { @@ -80,6 +110,7 @@ pub fn resolve_npm_dependencies( // probes — fully independent per specifier. let initial_entries: Vec<(String, PathBuf)> = specifiers .par_iter() + .filter(|spec| !is_external_specifier(spec, externals)) .filter_map(|spec| { let outcome = if spec.starts_with('#') { resolve::resolve_subpath_import(spec, None, project_root, conditions) @@ -134,6 +165,12 @@ pub fn resolve_npm_dependencies( let mut resolved_imports: Vec = Vec::with_capacity(scanned.len()); for import in &scanned { + // Honour `externalDependencies`: an import targeting an + // externalised package never enters the BFS, so the + // package's modules never reach the bundler. + if is_external_specifier(&import.specifier, externals) { + continue; + } let kind = if import.is_dynamic { ImportKind::Dynamic } else { @@ -307,6 +344,69 @@ mod tests { assert_eq!(result.edges.len(), 2, "should have 2 dependency edges"); } + #[test] + fn test_externals_skip_top_level_and_transitive() { + // Issue #146: a package listed in `externalDependencies` must NOT + // enter the resolution — neither when requested directly nor when + // reached transitively from another package. + let dir = tempfile::tempdir().unwrap(); + setup_crawl_fixture(dir.path()); + + let mut externals = HashSet::new(); + externals.insert("beta".to_string()); + + let result = resolve_npm_dependencies_with_externals( + &["alpha".to_string(), "beta".to_string()], + dir.path(), + DEV, + &externals, + ) + .expect("should resolve"); + + // alpha + utils.mjs only — beta is external so its index.mjs must + // not appear in modules and `beta` must not show up in resolved. + assert_eq!(result.modules.len(), 2, "beta's modules must not be pulled in"); + assert!( + !result.resolved_specifiers.contains("beta"), + "external 'beta' must not appear in resolved_specifiers" + ); + assert!(result.resolved_specifiers.contains("alpha")); + } + + #[test] + fn test_externals_match_subpath() { + let dir = tempfile::tempdir().unwrap(); + // Set up a single package that imports a subpath of an external pkg. + let pkg_dir = dir.path().join("node_modules/consumer"); + fs::create_dir_all(&pkg_dir).unwrap(); + fs::write( + pkg_dir.join("package.json"), + r#"{ "module": "./index.mjs" }"#, + ) + .unwrap(); + fs::write( + pkg_dir.join("index.mjs"), + "import slim from 'jquery/dist/jquery.slim';\nexport default slim;\n", + ) + .unwrap(); + + let mut externals = HashSet::new(); + externals.insert("jquery".to_string()); + + let result = resolve_npm_dependencies_with_externals( + &["consumer".to_string()], + dir.path(), + DEV, + &externals, + ) + .expect("should resolve"); + + // Only consumer is pulled in; the subpath import of jquery is + // treated as external and never walked. + assert_eq!(result.modules.len(), 1); + assert!(!result.resolved_specifiers.contains("jquery/dist/jquery.slim")); + } + #[test] fn test_crawl_deduplication() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/project-resolver/src/angular_json.rs b/crates/project-resolver/src/angular_json.rs index fe0c7cf..3f84d8b 100644 --- a/crates/project-resolver/src/angular_json.rs +++ b/crates/project-resolver/src/angular_json.rs @@ -160,6 +160,11 @@ pub struct RawBuildOptions { /// CDN libraries, polyfill shims that don't fit through `polyfills.ts`). /// Each entry is a string path or `{ input, inject, bundleName }` object. pub scripts: Option>, + /// npm package names that should NOT be bundled — their `import` statements + /// stay as bare ESM specifiers for the runtime (browser import map, CDN + /// loader, etc.) to resolve. Matches `@angular/build:application`'s + /// `externalDependencies` option. + pub external_dependencies: Option>, } /// One entry in `architect.build.options.budgets` (or in a per-configuration @@ -376,6 +381,11 @@ pub struct RawBuildConfiguration { /// of the base `define` map: same-key entries replace the base value, /// keys that appear only in the base are preserved. pub define: Option>, + /// Override for `externalDependencies`. When present, replaces the + /// base list (matches `@angular/build:application`'s semantics — the + /// configuration value wholly substitutes for the base value rather + /// than appending). + pub external_dependencies: Option>, } // --------------------------------------------------------------------------- @@ -515,6 +525,13 @@ pub struct ResolvedAngularProject { /// entry that shares the same `bundleName`. Empty when no `scripts` /// are declared. pub scripts: Vec, + /// npm package names declared as `externalDependencies` in + /// `angular.json`. Imports matching one of these specifiers stay as + /// bare ESM specifiers in the emitted bundle — the package is not + /// inlined and the resolver does not BFS into its modules. Matching + /// is by exact name or `/...` prefix, mirroring how + /// `@angular/build:application` (esbuild) treats package externals. + pub external_dependencies: Vec, } /// Type of a resolved size budget. @@ -816,6 +833,13 @@ pub fn resolve_angular_project( .map(|raw_scripts| resolve_scripts(raw_scripts, &base_dir)) .unwrap_or_default(); + // `externalDependencies` resolution: per-configuration override wholly + // replaces the base list when present (matching ng build's behaviour). + let external_dependencies = build_config + .and_then(|bc| bc.external_dependencies.clone()) + .or_else(|| options.and_then(|o| o.external_dependencies.clone())) + .unwrap_or_default(); + debug!( project = %name, output_path = %output_path.display(), @@ -847,6 +871,7 @@ pub fn resolve_angular_project( budgets, define, scripts, + external_dependencies, }) } @@ -1811,6 +1836,81 @@ mod tests { assert!(result.define.is_empty()); } + #[test] + fn test_parse_external_dependencies() { + let json = r#"{ + "projects": { + "app": { + "architect": { + "build": { + "options": { + "outputPath": "dist", + "tsConfig": "tsconfig.json", + "externalDependencies": ["jquery", "@stripe/stripe-js"] + } + } + } + } + } + }"#; + let f = write_temp_json(json); + let result = resolve_angular_project(f.path(), None, None).unwrap(); + assert_eq!( + result.external_dependencies, + vec!["jquery".to_string(), "@stripe/stripe-js".to_string()] + ); + } + + #[test] + fn test_external_dependencies_default_to_empty() { + let json = r#"{ + "projects": { + "app": { + "architect": { + "build": { + "options": { "outputPath": "dist", "tsConfig": "tsconfig.json" } + } + } + } + } + }"#; + let f = write_temp_json(json); + let result = resolve_angular_project(f.path(), None, None).unwrap(); + assert!(result.external_dependencies.is_empty()); + } + + #[test] + fn test_external_dependencies_configuration_override_replaces_base() { + // Per-configuration `externalDependencies` wholly replaces the base + // list (matches @angular/build:application). + let json = r#"{ + "projects": { + "app": { + "architect": { + "build": { + "options": { + "outputPath": "dist", + "tsConfig": "tsconfig.json", + "externalDependencies": ["jquery"] + }, + "configurations": { + "production": { + "externalDependencies": ["@stripe/stripe-js"] + } + } + } + } + } + } + }"#; + let f = write_temp_json(json); + let result = resolve_angular_project(f.path(), None, Some("production")).unwrap(); + assert_eq!( + result.external_dependencies, + vec!["@stripe/stripe-js".to_string()] + ); + } + #[test] fn test_no_i18n_block_resolves_to_none() { let json = r#"{ diff --git a/packages/builder/src/build/options.ts b/packages/builder/src/build/options.ts index aec4326..b019cea 100644 --- a/packages/builder/src/build/options.ts +++ b/packages/builder/src/build/options.ts @@ -175,11 +175,6 @@ export function translateOptions( 'The `outputHashing` option is hardcoded by ngc-rs per `--configuration` (production hashes bundles, development does not); the option value is ignored.', ); } - if (raw.externalDependencies && raw.externalDependencies.length > 0) { - warnings.push( - '`externalDependencies` is currently ignored by ngc-rs; all imports are bundled.', - ); - } if (Array.isArray(raw.localize)) { warnings.push( 'Selecting a locale subset via `localize` array is not yet honoured by ngc-rs; all locales declared in `angular.json` `i18n.locales` are emitted.', From b3654b66d3f0f57b59d120ab4c19476ac7d77ccd Mon Sep 17 00:00:00 2001 From: lukekania Date: Mon, 18 May 2026 11:16:50 +0200 Subject: [PATCH 04/20] feat(i18n): honor `localize: [...]` per-locale subset Switch `--localize` from a bool flag to an optional comma-separated list so CI builds can emit just the locales they need. ngc-rs build --localize # all i18n.locales (unchanged) ngc-rs build --localize=en-US,de # only those two subdirs The architect builder now serializes `localize: ['en', 'de']` as `--localize=en,de` instead of dropping the array and warning. An empty array still falls back to "all locales" to match `@angular/build`. `fan_out_locales` validates each subset entry against the source locale and `i18n.locales` keys; an unknown locale fails the build with a clear error rather than silently producing an empty `dist/`. --- Cargo.lock | 20 ++--- Cargo.toml | 2 +- crates/cli/src/main.rs | 80 +++++++++++++++---- crates/cli/src/serve_cmd.rs | 4 +- crates/cli/src/watch_cmd.rs | 7 +- packages/builder/schemas/application.json | 2 +- .../src/build/__tests__/options.test.ts | 15 +++- packages/builder/src/build/options.ts | 14 ++-- 8 files changed, 103 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c83a18c..1453bea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,7 +730,7 @@ dependencies = [ [[package]] name = "ngc-bundler" -version = "0.10.10" +version = "0.10.11" dependencies = [ "dashmap", "ngc-diagnostics", @@ -755,7 +755,7 @@ dependencies = [ [[package]] name = "ngc-dev-server" -version = "0.10.10" +version = "0.10.11" dependencies = [ "ngc-diagnostics", "serde_json", @@ -766,7 +766,7 @@ dependencies = [ [[package]] name = "ngc-diagnostics" -version = "0.10.10" +version = "0.10.11" dependencies = [ "serde_json", "thiserror", @@ -774,7 +774,7 @@ dependencies = [ [[package]] name = "ngc-linker" -version = "0.10.10" +version = "0.10.11" dependencies = [ "dashmap", "insta", @@ -792,7 +792,7 @@ dependencies = [ [[package]] name = "ngc-npm-resolver" -version = "0.10.10" +version = "0.10.11" dependencies = [ "dashmap", "ngc-diagnostics", @@ -807,7 +807,7 @@ dependencies = [ [[package]] name = "ngc-project-resolver" -version = "0.10.10" +version = "0.10.11" dependencies = [ "dashmap", "glob", @@ -823,7 +823,7 @@ dependencies = [ [[package]] name = "ngc-rs" -version = "0.10.10" +version = "0.10.11" dependencies = [ "base64", "clap", @@ -857,7 +857,7 @@ dependencies = [ [[package]] name = "ngc-template-compiler" -version = "0.10.10" +version = "0.10.11" dependencies = [ "insta", "ngc-diagnostics", @@ -879,7 +879,7 @@ dependencies = [ [[package]] name = "ngc-ts-transform" -version = "0.10.10" +version = "0.10.11" dependencies = [ "ngc-diagnostics", "oxc_allocator", @@ -898,7 +898,7 @@ dependencies = [ [[package]] name = "ngc-watch" -version = "0.10.10" +version = "0.10.11" dependencies = [ "ngc-diagnostics", "notify", diff --git a/Cargo.toml b/Cargo.toml index cadb00f..76169bc 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.10" +version = "0.10.11" 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 5aa980a..883e779 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -177,8 +177,12 @@ enum Commands { /// every `$localize\`...\`` literal in the bundled output. The /// source-locale build is moved under /// `//`. - #[arg(long)] - localize: bool, + /// + /// Pass `--localize` alone to emit every locale declared in + /// `i18n.locales`; pass `--localize=en,de` to restrict the output + /// to a subset (useful for trimming CI builds). + #[arg(long, num_args = 0..=1, value_delimiter = ',')] + localize: Option>, /// Treat any template that would fall back to JIT compilation as a /// hard error. Mirrors `@angular/build:application`, which has no /// JIT fallback. Defaults to on for `--configuration production` and @@ -203,8 +207,11 @@ enum Commands { configuration: Option, /// Emit one `//` tree per locale defined in /// `angular.json`'s `i18n.locales` block. - #[arg(long)] - localize: bool, + /// + /// Pass `--localize` alone for all locales, or `--localize=en,de` + /// to restrict the output to a subset. + #[arg(long, num_args = 0..=1, value_delimiter = ',')] + localize: Option>, }, /// Serve the project: build once, watch for changes, and host the /// resulting `dist/` directory over HTTP with live reload. Mirrors @@ -320,7 +327,7 @@ fn main() { &project, out_dir.as_deref(), configuration.as_deref(), - localize, + localize.as_deref(), Vec::new(), |_| false, ) { @@ -387,7 +394,7 @@ fn main() { &project, out_dir.as_deref(), configuration.as_deref(), - localize, + localize.as_deref(), strict_templates, ) { Ok(result) => { @@ -475,11 +482,16 @@ fn init_tracing() { } /// Orchestrate the full build pipeline: resolve → transform → bundle → output. +/// +/// `localize` mirrors the `--localize` CLI flag: `None` skips locale +/// fan-out entirely; `Some(&[])` emits every locale declared in +/// `i18n.locales`; `Some(&["en", "de"])` restricts the output to that +/// subset. fn run_build( project: &Path, out_dir_override: Option<&Path>, configuration: Option<&str>, - localize: bool, + localize: Option<&[String]>, strict_templates: bool, ) -> NgcResult { run_build_with_options( @@ -505,7 +517,7 @@ pub(crate) fn run_build_with_cache( project: &Path, out_dir_override: Option<&Path>, configuration: Option<&str>, - localize: bool, + localize: Option<&[String]>, cache: Option<&mut incremental::BuildCache>, ) -> NgcResult { run_build_with_options( @@ -533,7 +545,7 @@ pub(crate) fn run_build_with_options( project: &Path, out_dir_override: Option<&Path>, configuration: Option<&str>, - localize: bool, + localize: Option<&[String]>, strict_templates: bool, mut cache: Option<&mut incremental::BuildCache>, base_href_override: Option<&str>, @@ -1224,7 +1236,7 @@ pub(crate) fn run_build_with_options( // every other writer so it sees the final filenames + contents. if let Some(ref ap) = angular_project { if ap.service_worker { - if localize { + if localize.is_some() { tracing::warn!( "serviceWorker is enabled but --localize was passed; skipping ngsw.json (per-locale manifests are not yet supported)" ); @@ -1237,8 +1249,10 @@ pub(crate) fn run_build_with_options( // Step 13: --localize → fan the source-locale build out to // `//` and produce a translated copy under - // `//` for each entry in `i18n.locales`. - if localize { + // `//` for each entry in `i18n.locales`. A non-empty + // `subset` filters the emitted locales — useful for trimming CI builds + // that only need one or two locales per deploy. + if let Some(subset) = localize { let i18n = angular_project .as_ref() .and_then(|ap| ap.i18n.as_ref()) @@ -1247,7 +1261,7 @@ pub(crate) fn run_build_with_options( "--localize was passed but angular.json does not declare a `projects..i18n` block" .to_string(), })?; - let localized_files = fan_out_locales(&out_dir, i18n, &output_files)?; + let localized_files = fan_out_locales(&out_dir, i18n, subset, &output_files)?; output_files = localized_files; } @@ -1351,11 +1365,42 @@ pub(crate) fn run_build_with_options( /// Move the source-locale build under `//` and /// emit a translated copy under `//` for every entry in /// `i18n.locales`. Returns the new full set of output files. +/// +/// `subset` filters which locales are emitted. An empty slice emits every +/// locale (source plus all `i18n.locales` entries); a non-empty slice +/// restricts the output to the codes listed (validated against +/// `i18n.source_locale` and the keys of `i18n.locales`). fn fan_out_locales( out_dir: &Path, i18n: &I18nConfig, + subset: &[String], original_files: &[PathBuf], ) -> NgcResult> { + let include_source: bool; + let include_locale: Box bool>; + if subset.is_empty() { + include_source = true; + include_locale = Box::new(|_: &str| true); + } else { + // Reject `--localize=foo` when `foo` is neither the source locale + // nor one of the declared `i18n.locales` keys — silently skipping + // would let typos produce empty `dist/` runs in CI. + for code in subset { + let known = code == &i18n.source_locale || i18n.locales.contains_key(code.as_str()); + if !known { + return Err(NgcError::ConfigError { + message: format!( + "--localize subset entry `{code}` is not declared in angular.json `i18n.locales` (and is not the source locale `{}`)", + i18n.source_locale + ), + }); + } + } + include_source = subset.iter().any(|c| c == &i18n.source_locale); + let allow: std::collections::BTreeSet = subset.iter().cloned().collect(); + include_locale = Box::new(move |code: &str| allow.contains(code)); + } + // Materialize file contents from the original (source-locale) build so // we can write them back into per-locale directories without worrying // about the source-locale move clobbering them. @@ -1377,10 +1422,15 @@ fn fan_out_locales( let mut new_outputs: Vec = Vec::new(); - let source_dir = out_dir.join(&i18n.source_locale); - write_locale_tree(&source_dir, &sources, None, &mut new_outputs)?; + if include_source { + let source_dir = out_dir.join(&i18n.source_locale); + write_locale_tree(&source_dir, &sources, None, &mut new_outputs)?; + } for entry in i18n.locales.values() { + if !include_locale(entry.locale.as_str()) { + continue; + } let translations = match &entry.translation_path { Some(path) => Some(localize::parse_xliff(path)?), None => None, diff --git a/crates/cli/src/serve_cmd.rs b/crates/cli/src/serve_cmd.rs index aad8e89..7f91a77 100644 --- a/crates/cli/src/serve_cmd.rs +++ b/crates/cli/src/serve_cmd.rs @@ -74,7 +74,7 @@ pub(crate) fn run_with_stop( project, None, configuration, - false, + None, false, Some(&mut cache), normalized_serve_path.as_deref(), @@ -126,7 +126,7 @@ pub(crate) fn run_with_stop( &project_path, None, configuration_owned.as_deref(), - false, + None, false, Some(&mut cache), serve_path_owned.as_deref(), diff --git a/crates/cli/src/watch_cmd.rs b/crates/cli/src/watch_cmd.rs index bc0e208..0b5a8d4 100644 --- a/crates/cli/src/watch_cmd.rs +++ b/crates/cli/src/watch_cmd.rs @@ -25,11 +25,12 @@ pub fn run( project: &Path, out_dir_override: Option<&Path>, configuration: Option<&str>, - localize: bool, + localize: Option<&[String]>, subscribers: Vec>, should_stop: impl FnMut(usize) -> bool, ) -> NgcResult<()> { let mut cache = BuildCache::new(); + let localize_owned: Option> = localize.map(|s| s.to_vec()); // Initial build to populate the cache. `run_build_with_cache` always // disables `strict_templates` — `watch` is a dev workflow, so JIT @@ -39,7 +40,7 @@ pub fn run( project, out_dir_override, configuration, - localize, + localize_owned.as_deref(), Some(&mut cache), )?; eprintln!( @@ -76,7 +77,7 @@ pub fn run( &project_path, out_dir_path.as_deref(), configuration.as_deref(), - localize, + localize_owned.as_deref(), Some(&mut cache), )?; eprintln!( diff --git a/packages/builder/schemas/application.json b/packages/builder/schemas/application.json index 4eba804..a71efb6 100644 --- a/packages/builder/schemas/application.json +++ b/packages/builder/schemas/application.json @@ -275,7 +275,7 @@ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ], - "description": "Generate per-locale builds. When set to true ngc-rs is invoked with `--localize` and emits one output tree per `i18n.locales` entry in angular.json. Selecting a locale subset (array form) is NOT yet honoured by ngc-rs and logs a warning." + "description": "Generate per-locale builds. When set to true ngc-rs is invoked with `--localize` and emits one output tree per `i18n.locales` entry in angular.json. The array form `[\"en\", \"de\"]` serializes as `--localize=en,de` and restricts the output to the listed locales (useful for trimming CI builds)." }, "inlineStyleLanguage": { "type": "string", diff --git a/packages/builder/src/build/__tests__/options.test.ts b/packages/builder/src/build/__tests__/options.test.ts index 300af6e..c2e2496 100644 --- a/packages/builder/src/build/__tests__/options.test.ts +++ b/packages/builder/src/build/__tests__/options.test.ts @@ -76,14 +76,25 @@ describe('translateOptions (build)', () => { expect(unset.args).not.toContain('--strict-templates'); }); - it('appends --localize and warns when localize is an array (subset not yet honoured)', () => { + it('serializes a localize array as --localize=en,de (subset)', () => { const t = translateOptions( { ...minimal, localize: ['en', 'de'] }, '/ws', null, ); + expect(t.args).toContain('--localize=en,de'); + expect(t.args).not.toContain('--localize'); + expect(t.warnings.some((w) => w.includes('locale subset'))).toBe(false); + }); + + it('treats an empty localize array as `--localize` (all locales)', () => { + const t = translateOptions( + { ...minimal, localize: [] }, + '/ws', + null, + ); expect(t.args).toContain('--localize'); - expect(t.warnings.some((w) => w.includes('locale subset'))).toBe(true); + expect(t.args.some((a) => a.startsWith('--localize='))).toBe(false); }); it('accepts non-empty scripts arrays without error', () => { diff --git a/packages/builder/src/build/options.ts b/packages/builder/src/build/options.ts index b019cea..f2c1033 100644 --- a/packages/builder/src/build/options.ts +++ b/packages/builder/src/build/options.ts @@ -175,11 +175,6 @@ export function translateOptions( 'The `outputHashing` option is hardcoded by ngc-rs per `--configuration` (production hashes bundles, development does not); the option value is ignored.', ); } - if (Array.isArray(raw.localize)) { - warnings.push( - 'Selecting a locale subset via `localize` array is not yet honoured by ngc-rs; all locales declared in `angular.json` `i18n.locales` are emitted.', - ); - } if (raw.stylePreprocessorOptions) { const opts = raw.stylePreprocessorOptions as json.JsonObject; const includePaths = opts['includePaths']; @@ -223,7 +218,6 @@ export function translateOptions( const tsConfig = raw.tsConfig ?? 'tsconfig.json'; const outDir = resolveOutDir(raw.outputPath, workspaceRoot); - const localize = raw.localize === true || Array.isArray(raw.localize); const args: string[] = ['build', '--project', tsConfig, '--output-json']; if (configuration) { @@ -232,8 +226,14 @@ export function translateOptions( if (outDir) { args.push('--out-dir', outDir); } - if (localize) { + // `localize: true` → emit all locales (`--localize` with no value). + // `localize: ['en', 'de']` → emit just that subset (`--localize=en,de`). + // `localize: []` is treated as `true` to match `@angular/build`, which + // ignores an empty array and falls back to "all locales". + if (raw.localize === true || (Array.isArray(raw.localize) && raw.localize.length === 0)) { args.push('--localize'); + } else if (Array.isArray(raw.localize)) { + args.push(`--localize=${raw.localize.join(',')}`); } if (raw.strictTemplates === true) { args.push('--strict-templates'); From b2c5601b13d4d033915b7361d2281df161b2014b Mon Sep 17 00:00:00 2001 From: lukekania Date: Mon, 18 May 2026 10:27:30 +0200 Subject: [PATCH 05/20] feat(dev-server): support `allowedHosts` (Codespaces / ngrok / *.localhost) (#144) Add the `allowedHosts` knob the `@angular/build:dev-server` builder exposes so projects fronted by a tunneling proxy (ngrok, Cloudflare Tunnel, GitHub Codespaces) or running under a non-default local hostname (`*.localhost`, `app.local`) don't get rejected by the dev server's `Host:` header check. * `packages/builder/schemas/dev-server.json`: add `allowedHosts: array`. * `packages/builder/src/serve/options.ts`: forward the list as `--allowed-hosts host1,host2`, normalizing empty/whitespace entries and case-insensitively deduping. * `crates/cli`: new `--allowed-hosts` flag on `ngc-rs serve` (value delimiter `,`) wired through `serve_cmd::run` to the dev server. * `crates/dev-server`: new `AllowedHosts` resolver + filter in `handle_request`. Loopback hosts (`localhost`, `127.0.0.1`, `[::1]`) are always allowed. The literal `"all"` disables the check entirely; `"auto"` (or an empty list, the default) additionally accepts the bound host. Anything else is an exact, case-insensitive hostname match with the `Host:`-header port stripped before comparison. Mismatches respond with a 403 whose body names the offending host and points at both the angular.json option and the CLI flag. Bumps workspace version to 0.10.12. --- Cargo.lock | 20 +- Cargo.toml | 2 +- crates/cli/src/main.rs | 12 + crates/cli/src/serve_cmd.rs | 7 +- crates/cli/tests/serve_integration.rs | 1 + crates/dev-server/src/lib.rs | 323 +++++++++++++++++- crates/dev-server/tests/integration.rs | 110 ++++++ packages/builder/schemas/dev-server.json | 5 + .../src/serve/__tests__/options.test.ts | 35 ++ packages/builder/src/serve/options.ts | 34 ++ 10 files changed, 534 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1453bea..e70b989 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,7 +730,7 @@ dependencies = [ [[package]] name = "ngc-bundler" -version = "0.10.11" +version = "0.10.12" dependencies = [ "dashmap", "ngc-diagnostics", @@ -755,7 +755,7 @@ dependencies = [ [[package]] name = "ngc-dev-server" -version = "0.10.11" +version = "0.10.12" dependencies = [ "ngc-diagnostics", "serde_json", @@ -766,7 +766,7 @@ dependencies = [ [[package]] name = "ngc-diagnostics" -version = "0.10.11" +version = "0.10.12" dependencies = [ "serde_json", "thiserror", @@ -774,7 +774,7 @@ dependencies = [ [[package]] name = "ngc-linker" -version = "0.10.11" +version = "0.10.12" dependencies = [ "dashmap", "insta", @@ -792,7 +792,7 @@ dependencies = [ [[package]] name = "ngc-npm-resolver" -version = "0.10.11" +version = "0.10.12" dependencies = [ "dashmap", "ngc-diagnostics", @@ -807,7 +807,7 @@ dependencies = [ [[package]] name = "ngc-project-resolver" -version = "0.10.11" +version = "0.10.12" dependencies = [ "dashmap", "glob", @@ -823,7 +823,7 @@ dependencies = [ [[package]] name = "ngc-rs" -version = "0.10.11" +version = "0.10.12" dependencies = [ "base64", "clap", @@ -857,7 +857,7 @@ dependencies = [ [[package]] name = "ngc-template-compiler" -version = "0.10.11" +version = "0.10.12" dependencies = [ "insta", "ngc-diagnostics", @@ -879,7 +879,7 @@ dependencies = [ [[package]] name = "ngc-ts-transform" -version = "0.10.11" +version = "0.10.12" dependencies = [ "ngc-diagnostics", "oxc_allocator", @@ -898,7 +898,7 @@ dependencies = [ [[package]] name = "ngc-watch" -version = "0.10.11" +version = "0.10.12" dependencies = [ "ngc-diagnostics", "notify", diff --git a/Cargo.toml b/Cargo.toml index 76169bc..3408cfc 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.11" +version = "0.10.12" 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 883e779..922e4bc 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -242,6 +242,16 @@ enum Commands { /// to use the same value as its ``. #[arg(long = "serve-path")] serve_path: Option, + /// Comma-separated list of host names the dev server's + /// `Host:`-header check accepts. Loopback hosts (`localhost`, + /// `127.0.0.1`, `[::1]`) are always allowed. Pass `all` to + /// disable the check entirely, or `auto` to additionally accept + /// the configured bind host. Use this when fronting the dev + /// server with a tunneling proxy (ngrok, Cloudflare Tunnel, + /// GitHub Codespaces) or a non-default local hostname + /// (`*.localhost`, `app.local`). + #[arg(long = "allowed-hosts", value_delimiter = ',', num_args = 0..)] + allowed_hosts: Vec, }, /// Extract translatable messages from every component template in the /// project and emit a translation file (XLIFF 2.0 by default; XLIFF 1.2 @@ -342,6 +352,7 @@ fn main() { host, open, serve_path, + allowed_hosts, } => { if let Err(e) = serve_cmd::run( &project, @@ -350,6 +361,7 @@ fn main() { port, open, serve_path.as_deref(), + &allowed_hosts, ) { eprintln!("{} {e}", "Error:".red().bold()); process::exit(1); diff --git a/crates/cli/src/serve_cmd.rs b/crates/cli/src/serve_cmd.rs index 7f91a77..d0f989a 100644 --- a/crates/cli/src/serve_cmd.rs +++ b/crates/cli/src/serve_cmd.rs @@ -34,6 +34,7 @@ pub fn run( port: u16, open: bool, serve_path: Option<&str>, + allowed_hosts: &[String], ) -> NgcResult<()> { run_with_stop( project, @@ -42,6 +43,7 @@ pub fn run( port, open, serve_path, + allowed_hosts, install_ctrlc, ) } @@ -50,6 +52,7 @@ pub fn run( /// armed. Tests use a no-op installer so the watcher loop can be exited via /// the returned [`Arc`] without touching the real signal /// machinery (which would interfere with `cargo test`'s own handlers). +#[allow(clippy::too_many_arguments)] pub(crate) fn run_with_stop( project: &Path, configuration: Option<&str>, @@ -57,6 +60,7 @@ pub(crate) fn run_with_stop( port: u16, open: bool, serve_path: Option<&str>, + allowed_hosts: &[String], install_stop: impl FnOnce(Arc), ) -> NgcResult<()> { let out_dir = crate::resolve_out_dir(project, None, configuration)?; @@ -90,7 +94,8 @@ pub(crate) fn run_with_stop( let cfg = DevServerConfig::new(&out_dir) .with_host(host.to_string()) .with_port(port) - .with_serve_path(normalized_serve_path.as_deref()); + .with_serve_path(normalized_serve_path.as_deref()) + .with_allowed_hosts(allowed_hosts.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/cli/tests/serve_integration.rs b/crates/cli/tests/serve_integration.rs index 56a2669..1bf10ef 100644 --- a/crates/cli/tests/serve_integration.rs +++ b/crates/cli/tests/serve_integration.rs @@ -83,6 +83,7 @@ fn serve_help_lists_all_flags() { "--host", "--open", "--serve-path", + "--allowed-hosts", ] { assert!( stdout.contains(flag), diff --git a/crates/dev-server/src/lib.rs b/crates/dev-server/src/lib.rs index 800ff73..de1c226 100644 --- a/crates/dev-server/src/lib.rs +++ b/crates/dev-server/src/lib.rs @@ -93,6 +93,9 @@ pub struct DevServerConfig { /// server mounts at `/`. Mirrors `@angular/build:dev-server`'s /// `servePath` option for subpath deploys. pub serve_path: Option, + /// User-supplied `allowedHosts` patterns. Empty (= default) means + /// `auto`: loopback hosts plus the bind host. See [`AllowedHosts`]. + pub allowed_hosts: Vec, } impl DevServerConfig { @@ -104,6 +107,7 @@ impl DevServerConfig { host: "127.0.0.1".to_string(), port: 4200, serve_path: None, + allowed_hosts: Vec::new(), } } @@ -126,6 +130,17 @@ impl DevServerConfig { self.serve_path = serve_path.and_then(normalize_serve_path); self } + + /// Replace the `allowedHosts` patterns the dev server's Host-header + /// check accepts. See [`AllowedHosts`] for the matching semantics. + pub fn with_allowed_hosts(mut self, hosts: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.allowed_hosts = hosts.into_iter().map(Into::into).collect(); + self + } } /// Normalize a `servePath` string to the canonical `/foo/` form. @@ -154,6 +169,140 @@ pub fn normalize_serve_path(raw: &str) -> Option { } } +/// Decides whether an incoming HTTP request's `Host:` header is permitted. +/// +/// Mirrors `@angular/build:dev-server`'s `allowedHosts` option (which in +/// turn matches Vite's `server.allowedHosts`). Loopback hosts +/// (`localhost`, `127.0.0.1`, `[::1]`) are always accepted regardless of +/// configuration — local development must always work. On top of that: +/// +/// * The literal pattern `"all"` disables the check entirely. +/// * The literal pattern `"auto"` (or an empty configuration) additionally +/// accepts the bind host, so a server bound to `192.168.1.10` accepts +/// `Host: 192.168.1.10` without further configuration. +/// * Anything else is an exact, case-insensitive hostname match. The +/// port portion of the `Host:` header is stripped before comparison. +#[derive(Debug, Clone)] +pub struct AllowedHosts { + accept_all: bool, + explicit: Vec, + bind_host: Option, +} + +impl AllowedHosts { + /// Resolve the user-supplied `allowedHosts` patterns against the + /// `bind_host` the dev server is listening on. + /// + /// Empty input is treated as `"auto"` so callers that never opt in + /// still get the historical "loopback + bind host" behavior. + pub fn resolve(patterns: &[String], bind_host: &str) -> Self { + let mut accept_all = false; + let mut auto = false; + let mut explicit: Vec = Vec::new(); + let mut any = false; + for p in patterns { + any = true; + let trimmed = p.trim(); + if trimmed.is_empty() { + continue; + } + let lower = trimmed.to_ascii_lowercase(); + match lower.as_str() { + "all" => accept_all = true, + "auto" => auto = true, + _ => explicit.push(lower), + } + } + // Default (no patterns supplied) == "auto" — accept the bind host + // on top of the loopback defaults so projects that bind to a LAN + // IP still respond to that IP without explicit allow-listing. + if !any { + auto = true; + } + let bind_host = if auto { + normalized_bind_host(bind_host) + } else { + None + }; + Self { + accept_all, + explicit, + bind_host, + } + } + + /// Returns `true` when the dev server is configured to accept every + /// `Host:` header (i.e. the user passed `"all"`). + pub fn accepts_all(&self) -> bool { + self.accept_all + } + + /// Decide whether a request bearing this `Host:` header value should + /// be served. A missing or empty header counts as a mismatch. + pub fn is_allowed(&self, host_header: &str) -> bool { + if self.accept_all { + return true; + } + let stripped = strip_port(host_header.trim()); + if stripped.is_empty() { + return false; + } + let host = stripped.to_ascii_lowercase(); + if is_loopback_host(&host) { + return true; + } + if let Some(bh) = &self.bind_host { + if &host == bh { + return true; + } + } + self.explicit.iter().any(|p| p == &host) + } +} + +/// Lowercase + lookup-normalize a `bind_host` for use in `AllowedHosts`. +/// +/// Returns `None` when the bind host is a wildcard (`0.0.0.0`, `::`, `[::]`) +/// or a loopback alias — there's nothing useful to add beyond the +/// loopback defaults the allowlist already accepts. +fn normalized_bind_host(bind_host: &str) -> Option { + let host = bind_host.trim().to_ascii_lowercase(); + if host.is_empty() + || matches!(host.as_str(), "0.0.0.0" | "::" | "[::]") + || is_loopback_host(&host) + { + return None; + } + Some(host) +} + +fn is_loopback_host(host: &str) -> bool { + matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1") +} + +/// Strip the port from a `Host:` header value, leaving the hostname +/// (or IP literal) intact. +/// +/// Handles three shapes: +/// * `host` → `host` +/// * `host:port` → `host` +/// * `[v6]:port` → `[v6]` (brackets preserved so the value can be +/// compared against the canonical IPv6 loopback literal `[::1]`) +fn strip_port(host: &str) -> &str { + if let Some(rest) = host.strip_prefix('[') { + if let Some(end_rel) = rest.find(']') { + // end_rel is the position of `]` within `rest`; +2 accounts + // for the opening `[` we stripped and the `]` itself. + return &host[..end_rel + 2]; + } + return host; + } + match host.rfind(':') { + Some(i) => &host[..i], + None => host, + } +} + /// Handle to a running dev server. /// /// Dropping the handle stops the server and closes any open SSE connections. @@ -211,9 +360,19 @@ impl DevServer { let request_clients = Arc::clone(&clients); let serve_path = config.serve_path.clone(); 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 join = thread::Builder::new() .name("ngc-dev-server-accept".into()) - .spawn(move || serve_loop(request_server, root, request_clients, serve_path_for_loop)) + .spawn(move || { + serve_loop( + request_server, + root, + request_clients, + serve_path_for_loop, + allowed_hosts_for_loop, + ) + }) .map_err(|e| NgcError::ServeError { message: format!("could not spawn accept thread: {e}"), })?; @@ -341,13 +500,22 @@ pub fn sse_frame(event: &DevServerEvent) -> String { } } -fn serve_loop(server: Arc, root: PathBuf, clients: SseClients, serve_path: Option) { +fn serve_loop( + server: Arc, + root: PathBuf, + clients: SseClients, + serve_path: Option, + allowed_hosts: 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); thread::spawn(move || { - if let Err(e) = handle_request(request, &root, &clients, serve_path.as_deref()) { + if let Err(e) = + handle_request(request, &root, &clients, serve_path.as_deref(), &allowed_hosts) + { tracing::warn!(error = %e, "dev server request failed"); } }); @@ -359,12 +527,18 @@ fn handle_request( root: &Path, clients: &SseClients, serve_path: Option<&str>, + allowed_hosts: &AllowedHosts, ) -> NgcResult<()> { if !matches!(request.method(), Method::Get | Method::Head) { let resp = Response::from_string("method not allowed").with_status_code(StatusCode(405)); return request.respond(resp).map_err(io_err); } + let host_header = host_header_value(&request); + if !allowed_hosts.is_allowed(&host_header) { + return respond_disallowed_host(request, &host_header); + } + let url = request.url().to_string(); let path = url.split('?').next().unwrap_or("/"); @@ -383,6 +557,41 @@ fn handle_request( serve_static(request, root, stripped, serve_path) } +/// 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 +/// that case the allow-list check treats it as a mismatch. +fn host_header_value(request: &tiny_http::Request) -> String { + request + .headers() + .iter() + .find(|h| h.field.equiv("Host")) + .map(|h| h.value.as_str().to_string()) + .unwrap_or_default() +} + +/// Render the 403 returned for a `Host:` header that's not in the allow +/// list. The body is plain text and points the user at the two knobs that +/// fix it — same wording for the CLI flag and the builder option so a +/// search of either turns up the same hit. +fn respond_disallowed_host(request: tiny_http::Request, host_header: &str) -> NgcResult<()> { + let display = if host_header.is_empty() { + "".to_string() + } else { + host_header.to_string() + }; + let body = format!( + "ngc-rs dev server: blocked request for host \"{display}\".\n\n\ + The host is not in the dev server's allowedHosts list.\n\ + To allow it, either:\n\ + - add it to `architect.serve.options.allowedHosts` in angular.json, or\n\ + - pass `--allowed-hosts {display}` to `ngc-rs serve`.\n\ + Use `\"all\"` to disable the host check entirely.\n" + ); + let resp = Response::from_string(body).with_status_code(StatusCode(403)); + request.respond(resp).map_err(io_err) +} + /// Strip the `serve_path` prefix from `path`, returning the remainder /// (always starting with `/`). Returns `None` when `path` falls outside /// the prefix and should be served as 404. @@ -974,4 +1183,112 @@ mod tests { assert!(script.contains("addEventListener('reload'")); assert!(script.contains("addEventListener('build-failed'")); } + + #[test] + fn strip_port_handles_bare_hostname() { + assert_eq!(strip_port("example.com"), "example.com"); + assert_eq!(strip_port("localhost"), "localhost"); + } + + #[test] + fn strip_port_drops_port_from_ipv4_and_hostname() { + assert_eq!(strip_port("example.com:4200"), "example.com"); + assert_eq!(strip_port("127.0.0.1:4200"), "127.0.0.1"); + } + + #[test] + fn strip_port_preserves_ipv6_brackets() { + assert_eq!(strip_port("[::1]"), "[::1]"); + assert_eq!(strip_port("[::1]:4200"), "[::1]"); + assert_eq!(strip_port("[2001:db8::1]:8080"), "[2001:db8::1]"); + } + + #[test] + fn allowed_hosts_default_accepts_loopback_and_bind_host() { + let ah = AllowedHosts::resolve(&[], "192.168.1.10"); + assert!(ah.is_allowed("localhost")); + assert!(ah.is_allowed("localhost:4200")); + assert!(ah.is_allowed("127.0.0.1")); + assert!(ah.is_allowed("[::1]:4200")); + assert!(ah.is_allowed("192.168.1.10")); + assert!(ah.is_allowed("192.168.1.10:4200")); + assert!(!ah.is_allowed("my-app.ngrok.io")); + assert!(!ah.is_allowed("evil.example.com")); + } + + #[test] + fn allowed_hosts_all_accepts_anything() { + let ah = AllowedHosts::resolve(&["all".to_string()], "127.0.0.1"); + assert!(ah.accepts_all()); + assert!(ah.is_allowed("evil.example.com")); + assert!(ah.is_allowed("my-app.ngrok.io:443")); + // An empty Host header still counts as accepted when the user + // opted in to "all" — that's the documented bypass. + assert!(ah.is_allowed("")); + } + + #[test] + fn allowed_hosts_explicit_matches_exact_hostnames_case_insensitively() { + let ah = AllowedHosts::resolve(&["my-app.ngrok.io".to_string()], "127.0.0.1"); + assert!(ah.is_allowed("my-app.ngrok.io")); + assert!(ah.is_allowed("My-App.NgRoK.io")); + assert!(ah.is_allowed("my-app.ngrok.io:8443")); + assert!(!ah.is_allowed("other.ngrok.io")); + assert!(!ah.is_allowed("evil.com")); + // Loopback is always accepted on top of explicit entries. + assert!(ah.is_allowed("localhost")); + assert!(ah.is_allowed("127.0.0.1")); + } + + #[test] + 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", + ); + assert!(!ah.is_allowed("192.168.1.10")); + assert!(ah.is_allowed("my-app.ngrok.io")); + } + + #[test] + fn allowed_hosts_auto_re_enables_bind_host_alongside_explicit_entries() { + let ah = AllowedHosts::resolve( + &["auto".to_string(), "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")); + assert!(!ah.is_allowed("evil.com")); + } + + #[test] + fn allowed_hosts_rejects_missing_host_header_by_default() { + let ah = AllowedHosts::resolve(&[], "127.0.0.1"); + assert!(!ah.is_allowed("")); + assert!(!ah.is_allowed(" ")); + } + + #[test] + fn allowed_hosts_skips_wildcard_bind_address() { + // Binding to 0.0.0.0 doesn't auto-allow "0.0.0.0" as a hostname — + // that's never a meaningful Host: header value. Loopback still works. + let ah = AllowedHosts::resolve(&[], "0.0.0.0"); + assert!(ah.is_allowed("localhost")); + assert!(ah.is_allowed("127.0.0.1")); + assert!(!ah.is_allowed("0.0.0.0")); + assert!(!ah.is_allowed("192.168.1.10")); + } + + #[test] + fn allowed_hosts_ignores_empty_and_whitespace_patterns() { + let ah = AllowedHosts::resolve( + &["".to_string(), " ".to_string(), "ok.example".to_string()], + "127.0.0.1", + ); + assert!(ah.is_allowed("ok.example")); + assert!(ah.is_allowed("localhost")); + assert!(!ah.is_allowed("nope.example")); + } } diff --git a/crates/dev-server/tests/integration.rs b/crates/dev-server/tests/integration.rs index a8e9638..b043474 100644 --- a/crates/dev-server/tests/integration.rs +++ b/crates/dev-server/tests/integration.rs @@ -443,6 +443,116 @@ 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 { + 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"); + 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"); + let status: u16 = status_line + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .expect("status code"); + + let mut headers = Vec::new(); + loop { + let mut line = String::new(); + reader.read_line(&mut line).expect("header line"); + if line == "\r\n" || line.is_empty() { + break; + } + if let Some((k, v)) = line.trim_end_matches("\r\n").split_once(':') { + headers.push((k.trim().to_string(), v.trim().to_string())); + } + } + let mut body = Vec::new(); + reader.read_to_end(&mut body).expect("body"); + HttpResponse { + status, + headers, + body, + } +} + +fn allowed_hosts_fixture(patterns: &[&str]) -> Fixture { + let root = TempDir::new().expect("tempdir"); + write_file( + root.path(), + "index.html", + b"

hi

", + ); + let cfg = DevServerConfig::new(root.path()) + .with_port(0) + .with_allowed_hosts(patterns.iter().copied()); + let (_tx, rx) = channel::(); + let server = DevServer::start(cfg, rx).expect("start dev server"); + Fixture { + server, + _root: root, + } +} + +#[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); + 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(""); + assert!( + body.contains("my-app.ngrok.io") && body.contains("allowedHosts"), + "403 body should name the host and point at allowedHosts: {body}" + ); +} + +#[test] +fn explicit_allowed_host_lets_ngrok_traffic_through() { + let fx = allowed_hosts_fixture(&["my-app.ngrok.io"]); + assert_eq!( + http_get_with_host(fx.server.addr(), "/", "my-app.ngrok.io").status, + 200 + ); + // Port stripping: a tunneling proxy may forward Host with a port. + assert_eq!( + http_get_with_host(fx.server.addr(), "/", "my-app.ngrok.io:8443").status, + 200 + ); + // Loopback still works. + 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, + 403 + ); +} + +#[test] +fn allowed_hosts_all_disables_check() { + let fx = allowed_hosts_fixture(&["all"]); + assert_eq!( + http_get_with_host(fx.server.addr(), "/", "anything.example.com").status, + 200 + ); + assert_eq!( + http_get_with_host(fx.server.addr(), "/", "my-app.ngrok.io").status, + 200 + ); +} + #[test] fn prefixed_sse_channel_is_reachable_under_prefix() { let fx = prefixed_fixture("/admin/"); diff --git a/packages/builder/schemas/dev-server.json b/packages/builder/schemas/dev-server.json index abb9ad3..449ab8b 100644 --- a/packages/builder/schemas/dev-server.json +++ b/packages/builder/schemas/dev-server.json @@ -63,6 +63,11 @@ "servePath": { "type": "string", "description": "URL path prefix to mount the dev server under (e.g. \"/admin/\"). Mirrors @angular/build:dev-server's servePath option for projects deployed behind a subpath. When set, the served index.html is rewritten to use the same value as unless angular.json already configures one explicitly." + }, + "allowedHosts": { + "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)." } }, "additionalProperties": false diff --git a/packages/builder/src/serve/__tests__/options.test.ts b/packages/builder/src/serve/__tests__/options.test.ts index f6a919a..9acd993 100644 --- a/packages/builder/src/serve/__tests__/options.test.ts +++ b/packages/builder/src/serve/__tests__/options.test.ts @@ -100,6 +100,41 @@ describe('translateOptions', () => { translateOptions({ ...base, servePath: '' }, '/ws').args, ).not.toContain('--serve-path'); }); + + it('forwards a non-empty allowedHosts list as a comma-joined --allowed-hosts arg', () => { + const t = translateOptions( + { ...base, allowedHosts: ['my-app.ngrok.io', 'app.local'] }, + '/ws', + ); + const idx = t.args.indexOf('--allowed-hosts'); + expect(idx).toBeGreaterThanOrEqual(0); + expect(t.args[idx + 1]).toBe('my-app.ngrok.io,app.local'); + }); + + it('passes through the "all" sentinel verbatim', () => { + const t = translateOptions({ ...base, allowedHosts: ['all'] }, '/ws'); + const idx = t.args.indexOf('--allowed-hosts'); + expect(t.args[idx + 1]).toBe('all'); + }); + + it('drops empty / whitespace-only allowedHosts entries and dedupes case-insensitively', () => { + const t = translateOptions( + { + ...base, + allowedHosts: ['', ' ', 'foo.example', 'Foo.Example', 'bar.example'], + }, + '/ws', + ); + const idx = t.args.indexOf('--allowed-hosts'); + expect(t.args[idx + 1]).toBe('foo.example,bar.example'); + }); + + it('omits --allowed-hosts when the list is empty or unset', () => { + expect( + translateOptions({ ...base, allowedHosts: [] }, '/ws').args, + ).not.toContain('--allowed-hosts'); + expect(translateOptions(base, '/ws').args).not.toContain('--allowed-hosts'); + }); }); describe('formatUrl', () => { diff --git a/packages/builder/src/serve/options.ts b/packages/builder/src/serve/options.ts index b9c36c8..1f7a613 100644 --- a/packages/builder/src/serve/options.ts +++ b/packages/builder/src/serve/options.ts @@ -15,6 +15,7 @@ export interface DevServerOptions extends json.JsonObject { define: { [key: string]: string } | null; watch: boolean | null; servePath: string | null; + allowedHosts: string[] | null; } export interface TranslatedServeArgs { @@ -79,6 +80,10 @@ export function translateOptions( if (servePath) { args.push('--serve-path', servePath); } + const allowedHosts = normalizeAllowedHosts(raw.allowedHosts); + if (allowedHosts.length > 0) { + args.push('--allowed-hosts', allowedHosts.join(',')); + } return { args, @@ -114,6 +119,35 @@ function normalizeServePath(raw: string | null | undefined): string | null { return out; } +// Strip empty/whitespace-only entries and dedupe (case-insensitive on the +// host portion) so the downstream CLI receives a clean comma-joined list. +// Order of distinct entries is preserved, since order shouldn't matter for +// a set-membership check but stable args make `--help` traces easier to +// diff between runs. +function normalizeAllowedHosts(raw: string[] | null | undefined): string[] { + if (!raw || raw.length === 0) { + return []; + } + const seen = new Set(); + const out: string[] = []; + for (const entry of raw) { + if (typeof entry !== 'string') { + continue; + } + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const key = trimmed.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + out.push(trimmed); + } + return out; +} + function parseConfigurationFromBuildTarget(buildTarget?: string): string | null { if (!buildTarget) { return null; From 800012af69082db680b0673e8efc1e2d150fb01f Mon Sep 17 00:00:00 2001 From: lukekania Date: Mon, 18 May 2026 10:19:34 +0200 Subject: [PATCH 06/20] feat(bundler): per-provider tree-shake for vendor chunks (#171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize cross-chunk used-names collection so every chunk gets its own externally-used set, not just main. Vendor chunks holding `@angular/core` or `rxjs` previously pinned every export the package declared because `externally_used = None` made the shaker fall back to entry-walk reachability — on `index.mjs`-style packages that reaches almost everything. - `shake::collect_cross_chunk_used_names_per_provider` returns `Vec>` indexed by chunk index; for each chunk i it collects the names other chunks import from any module in i. - `bundle()` builds a bare-specifier → canonical-path map from the existing namespace tables so bare imports (`'@angular/core'`) attribute to the owning vendor chunk, then feeds `externally_used_per_chunk[idx]` into `analyze_unused_exports` for every chunk — the `is_main` gate is dropped. - `npm_wrap::wrap_npm_module` now accepts `unused_exports` and drops both the unused `export const X = ...` declarations and the matching `__exports.X = ns.X` re-export bridges, so shake decisions reach the emitted vendor chunk code. Bumps version to 0.10.13. --- Cargo.lock | 20 +- Cargo.toml | 2 +- crates/bundler/src/concat.rs | 51 ++- crates/bundler/src/npm_wrap.rs | 94 ++++- crates/bundler/src/shake.rs | 355 +++++++++++++----- .../vendor_chunk_splitting_integration.rs | 173 +++++++++ 6 files changed, 552 insertions(+), 143 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e70b989..6bed691 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,7 +730,7 @@ dependencies = [ [[package]] name = "ngc-bundler" -version = "0.10.12" +version = "0.10.13" dependencies = [ "dashmap", "ngc-diagnostics", @@ -755,7 +755,7 @@ dependencies = [ [[package]] name = "ngc-dev-server" -version = "0.10.12" +version = "0.10.13" dependencies = [ "ngc-diagnostics", "serde_json", @@ -766,7 +766,7 @@ dependencies = [ [[package]] name = "ngc-diagnostics" -version = "0.10.12" +version = "0.10.13" dependencies = [ "serde_json", "thiserror", @@ -774,7 +774,7 @@ dependencies = [ [[package]] name = "ngc-linker" -version = "0.10.12" +version = "0.10.13" dependencies = [ "dashmap", "insta", @@ -792,7 +792,7 @@ dependencies = [ [[package]] name = "ngc-npm-resolver" -version = "0.10.12" +version = "0.10.13" dependencies = [ "dashmap", "ngc-diagnostics", @@ -807,7 +807,7 @@ dependencies = [ [[package]] name = "ngc-project-resolver" -version = "0.10.12" +version = "0.10.13" dependencies = [ "dashmap", "glob", @@ -823,7 +823,7 @@ dependencies = [ [[package]] name = "ngc-rs" -version = "0.10.12" +version = "0.10.13" dependencies = [ "base64", "clap", @@ -857,7 +857,7 @@ dependencies = [ [[package]] name = "ngc-template-compiler" -version = "0.10.12" +version = "0.10.13" dependencies = [ "insta", "ngc-diagnostics", @@ -879,7 +879,7 @@ dependencies = [ [[package]] name = "ngc-ts-transform" -version = "0.10.12" +version = "0.10.13" dependencies = [ "ngc-diagnostics", "oxc_allocator", @@ -898,7 +898,7 @@ dependencies = [ [[package]] name = "ngc-watch" -version = "0.10.12" +version = "0.10.13" dependencies = [ "ngc-diagnostics", "notify", diff --git a/Cargo.toml b/Cargo.toml index 3408cfc..44371e9 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.12" +version = "0.10.13" edition = "2021" license = "MIT OR Apache-2.0" authors = ["lukekania"] diff --git a/crates/bundler/src/concat.rs b/crates/bundler/src/concat.rs index fc96c57..b06b40b 100644 --- a/crates/bundler/src/concat.rs +++ b/crates/bundler/src/concat.rs @@ -163,24 +163,40 @@ pub fn bundle(input: &BundleInput) -> NgcResult { .map(|c| c.filename.clone()) .collect(); - // Lazy chunks consume symbols from main cross-chunk; those consumptions - // are invisible to the per-chunk shake analysis. Precompute the union - // before fan-out so the main chunk's analyze_unused_exports preserves - // them. - let externally_used: Option> = if input.options.tree_shake { - let mut lazy_consumers: Vec = Vec::new(); - for chunk in &chunk_graph.chunks[1..] { - lazy_consumers.extend(chunk.modules.iter().cloned()); + // Every chunk consumes symbols from other chunks (main from lazy, lazy + // from main, lazy from vendor, ...) — those consumptions are invisible + // to per-chunk shake analysis. Precompute the per-provider used-name + // set before fan-out so each chunk's analyze_unused_exports preserves + // exactly what its consumers reach into. + // + // For bare npm specifiers (e.g. `'@angular/core'`), build a + // specifier → canonical-entry-path map by composing the bare-spec → + // namespace and namespace → owning-path lookups we already computed. + // Without this, the shake walker can't attribute an `import { X } from + // '@angular/core'` to its vendor chunk and the chunk falls back to + // pinning every export the package declares. + let externally_used_per_chunk: Vec> = if input.options.tree_shake { + let mut ns_to_path: HashMap<&str, &PathBuf> = HashMap::new(); + for (path, ns) in &all_file_to_ns { + ns_to_path.insert(ns.as_str(), path); } - Some(shake::collect_cross_chunk_used_names( - &lazy_consumers, - &main_chunk.modules, + let specifier_to_path: HashMap = specifier_to_namespace + .iter() + .filter_map(|(spec, ns)| { + ns_to_path + .get(ns.as_str()) + .map(|path| (spec.clone(), (*path).clone())) + }) + .collect(); + shake::collect_cross_chunk_used_names_per_provider( + &chunk_graph, &input.modules, &prefix_refs, + &specifier_to_path, subpath_ctx, - )?) + )? } else { - None + vec![HashSet::new(); chunk_graph.chunks.len()] }; // Process every chunk (main + lazy/shared) in a single rayon fan-out. @@ -194,13 +210,8 @@ pub fn bundle(input: &BundleInput) -> NgcResult { .par_iter() .enumerate() .map(|(idx, chunk)| -> NgcResult<(String, ChunkBundleResult)> { - let is_main = idx == 0; let unused_exports = if input.options.tree_shake { - let externally_used_ref = if is_main { - externally_used.as_ref() - } else { - None - }; + let externally_used_ref = externally_used_per_chunk.get(idx); shake::analyze_unused_exports( &chunk.modules, &input.modules, @@ -648,10 +659,12 @@ fn bundle_chunk(p: &ChunkBundleParams<'_>) -> NgcResult { // or another — cross-chunk refs become imports at the // chunk's top, emitted in the post-process pass. let namespace = &file_to_namespace[module_path]; + let module_unused = p.unused_exports.get(module_path); let wrapped = crate::npm_wrap::wrap_npm_module( js_code, &file_name, namespace, + module_unused, |specifier| { if specifier.starts_with('.') { let from_dir = module_path.parent()?; diff --git a/crates/bundler/src/npm_wrap.rs b/crates/bundler/src/npm_wrap.rs index 635caf9..64b6a04 100644 --- a/crates/bundler/src/npm_wrap.rs +++ b/crates/bundler/src/npm_wrap.rs @@ -15,7 +15,7 @@ //! })(__ns_abc123); //! ``` -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::Path; use ngc_diagnostics::{NgcError, NgcResult}; @@ -39,15 +39,29 @@ pub struct NpmModuleInfo { /// /// `resolve_import` is a closure that maps an import specifier to a namespace /// variable name, or `None` if the import should be left as-is (truly external). +/// +/// `unused_exports` carries the set of exported names that per-provider +/// shake decided no consumer reaches. When provided, both the declarations +/// (`export const X = ...`) and the matching `__exports.X = ...` bridge +/// lines are dropped, shrinking vendor chunks of large packages like +/// `@angular/core` whose `index.mjs` re-exports far more than any consumer +/// actually uses. pub fn wrap_npm_module( js_code: &str, file_name: &str, namespace: &str, + unused_exports: Option<&HashSet>, resolve_import: F, ) -> NgcResult where F: Fn(&str) -> Option, { + let is_unused = |name: &str| -> bool { + unused_exports + .map(|set| set.contains(name)) + .unwrap_or(false) + }; + // Strip sourcemap comments upfront to prevent them from interfering with // the IIFE wrapping (they can eat export assignments on the same line). let cleaned_code = strip_sourcemap_comments(js_code); @@ -133,6 +147,9 @@ where for spec in &export.specifiers { let exported = spec.exported.name().to_string(); let local = spec.local.name().to_string(); + if is_unused(&exported) { + continue; + } // Don't add to exported_names — we handle the export inline if let Some(ref ns) = target_ns { replacements.push(format!("__exports.{exported} = {ns}.{local};")); @@ -150,21 +167,39 @@ where end: export.span.end, replacement, }); - } else if export.declaration.is_some() { - // export const X = ...; → strip "export " - if let Some(decl) = &export.declaration { - collect_decl_names(decl, &mut exported_names); + } else if let Some(decl) = &export.declaration { + // export const X = ...; — if X is unused, drop the + // entire declaration so the body isn't pinned by + // its `__exports.X = X` line and any const + // initializer side-effect is also eliminated. + let mut decl_names = Vec::new(); + collect_decl_names(decl, &mut decl_names); + let all_unused = + !decl_names.is_empty() && decl_names.iter().all(|n| is_unused(n)); + if all_unused { + edits.push(TextEdit { + start: export.span.start, + end: export.span.end, + replacement: None, + }); + } else { + for n in &decl_names { + exported_names.push(n.clone()); + } + edits.push(TextEdit { + start: export.span.start, + end: export.span.start + 7, // "export " + replacement: None, + }); } - edits.push(TextEdit { - start: export.span.start, - end: export.span.start + 7, // "export " - replacement: None, - }); } else { // export { X, Y }; or export { X as Y }; → collect names and remove for spec in &export.specifiers { let exported = spec.exported.name().to_string(); let local = spec.local.name().to_string(); + if is_unused(&exported) { + continue; + } if exported != local { renamed_exports.insert(exported.clone(), local); } @@ -428,7 +463,7 @@ mod tests { #[test] fn test_wrap_simple_module() { let code = "export function hello() { return 42; }\n"; - let result = wrap_npm_module(code, "test.js", "__ns_test", no_resolve).unwrap(); + let result = wrap_npm_module(code, "test.js", "__ns_test", None, no_resolve).unwrap(); assert!(result.wrapped_code.contains("var __ns_test = {};")); assert!(result.wrapped_code.contains("(function(__exports)")); assert!(result.wrapped_code.contains("__exports.hello = hello;")); @@ -447,7 +482,7 @@ mod tests { None } }; - let result = wrap_npm_module(code, "test.js", "__ns_test", resolve).unwrap(); + let result = wrap_npm_module(code, "test.js", "__ns_test", None, resolve).unwrap(); assert!(result .wrapped_code .contains("var Component = __ns_core.Component;")); @@ -465,7 +500,7 @@ mod tests { None } }; - let result = wrap_npm_module(code, "test.js", "__ns_test", resolve).unwrap(); + let result = wrap_npm_module(code, "test.js", "__ns_test", None, resolve).unwrap(); assert!(result .wrapped_code .contains("Object.assign(__exports, __ns_utils)")); @@ -474,11 +509,42 @@ mod tests { #[test] fn test_wrap_default_export() { let code = "export default function helper() { return 1; }\n"; - let result = wrap_npm_module(code, "test.js", "__ns_test", no_resolve).unwrap(); + let result = wrap_npm_module(code, "test.js", "__ns_test", None, no_resolve).unwrap(); assert!(result.wrapped_code.contains("function helper()")); assert!(result.wrapped_code.contains("__exports.default = helper;")); } + #[test] + fn test_wrap_drops_unused_declaration() { + let code = "export const used = 1;\nexport const unused = 2;\n"; + let mut unused: HashSet = HashSet::new(); + unused.insert("unused".to_string()); + let result = + wrap_npm_module(code, "test.js", "__ns_test", Some(&unused), no_resolve).unwrap(); + assert!(result.wrapped_code.contains("const used = 1")); + assert!(!result.wrapped_code.contains("const unused = 2")); + assert!(result.wrapped_code.contains("__exports.used = used;")); + assert!(!result.wrapped_code.contains("__exports.unused")); + } + + #[test] + fn test_wrap_drops_unused_reexport_bridge() { + let code = "export { used, unused } from './impl';\n"; + let resolve = |spec: &str| -> Option { + if spec == "./impl" { + Some("__ns_impl".to_string()) + } else { + None + } + }; + let mut unused: HashSet = HashSet::new(); + unused.insert("unused".to_string()); + let result = + wrap_npm_module(code, "test.js", "__ns_test", Some(&unused), resolve).unwrap(); + assert!(result.wrapped_code.contains("__exports.used = __ns_impl.used")); + assert!(!result.wrapped_code.contains("__exports.unused")); + } + #[test] fn test_namespace_from_path() { use std::path::PathBuf; diff --git a/crates/bundler/src/shake.rs b/crates/bundler/src/shake.rs index 29ae265..239e76a 100644 --- a/crates/bundler/src/shake.rs +++ b/crates/bundler/src/shake.rs @@ -337,106 +337,138 @@ fn resolve_local_specifier( None } -/// Collect symbol names imported by `consumer_modules` from any module in -/// `provider_modules`, by parsing each consumer's source for `ImportDeclaration` -/// statements and resolving their specifiers against the provider set. +/// Per-provider collection of names consumed cross-chunk. /// -/// Used by the bundler to tell the main-chunk tree-shaker which symbols are -/// consumed by lazy chunks and must therefore be preserved — such consumption -/// is invisible when shaking each chunk in isolation, and would otherwise -/// leave the cross-chunk `export { ... }` block referring to names whose -/// declarations have been tree-shaken away. +/// Returns a `Vec>` indexed by chunk index. `result[i]` holds +/// the set of names that modules in *other* chunks import from any module +/// owned by chunk `i`. Used by the bundler's per-chunk tree-shaker so a +/// vendor chunk holding `@angular/core` / `rxjs` can drop exports no +/// consumer references, instead of pinning every name the package declares +/// just because its entry walk happens to reach them. /// -/// For named and default imports, the specific name is collected. For -/// namespace imports (`import * as X from '...'`), every exported name of -/// the target provider module is collected since individual accesses can't -/// be known statically here. -pub fn collect_cross_chunk_used_names( - consumer_modules: &[PathBuf], - provider_modules: &[PathBuf], +/// `specifier_to_path` resolves bare npm specifiers (`'@angular/core'`) to +/// the canonical entry-module path so bare-specifier imports can be +/// attributed to their owning provider chunk. Relative and `#`-subpath +/// imports flow through [`resolve_local_specifier`] as usual. +pub fn collect_cross_chunk_used_names_per_provider( + chunk_graph: &crate::chunk::ChunkGraph, all_code: &HashMap, local_prefixes: &[&str], + specifier_to_path: &HashMap, subpath_ctx: Option>, -) -> NgcResult> { - let provider_set: HashSet<&PathBuf> = provider_modules.iter().collect(); +) -> NgcResult>> { + let n = chunk_graph.chunks.len(); + let mut result: Vec> = vec![HashSet::new(); n]; + if n == 0 { + return Ok(result); + } - // Phase A (parallel): parse each consumer and extract the named symbols - // it imports from provider modules. The parse dominates, so fanning this - // out across rayon workers recovers most of the tree-shake wall time. - let per_consumer: Vec> = consumer_modules + let module_to_chunk_idx = &chunk_graph.module_to_chunk_idx; + + // Flat (consumer_chunk_idx, consumer_path) list — every module across + // every chunk is a potential consumer of names in some other chunk. + let consumers: Vec<(usize, PathBuf)> = chunk_graph + .chunks + .iter() + .enumerate() + .flat_map(|(idx, chunk)| chunk.modules.iter().map(move |m| (idx, m.clone()))) + .collect(); + + // The full provider candidate set — every module across all chunks. + // `resolve_local_specifier` scans this when matching a relative import + // path; chunk membership is then read from `module_to_chunk_idx`. + let all_provider_paths: Vec = consumers.iter().map(|(_, p)| p.clone()).collect(); + + // Phase A (parallel): for each consumer, parse once and produce a list + // of (target_chunk_idx, imported_name) entries. Intra-chunk imports + // are dropped here — those are handled by `analyze_unused_exports`'s + // per-chunk reachability pass. + let per_consumer: Vec> = consumers .par_iter() - .filter_map(|consumer_path| { + .filter_map(|(consumer_idx, consumer_path)| { all_code .get(consumer_path) - .map(|code| (consumer_path, code)) + .map(|code| (*consumer_idx, consumer_path.clone(), code)) }) - .map(|(consumer_path, code)| -> NgcResult> { - let info = analyze_module(code, consumer_path)?; - let mut local_used: HashSet = HashSet::new(); - for (specifier, imported_names) in &info.local_imports { - let Some(target) = resolve_local_specifier( - specifier, - consumer_path, - provider_modules, - local_prefixes, - subpath_ctx, - ) else { - continue; - }; - if !provider_set.contains(&target) { - continue; - } - for name in imported_names { - if name == "* as " || name.starts_with("* as ") { - // ImportNamespaceSpecifier — `analyze_module` currently - // drops these (returns None). Left defensive; the - // namespace pass below handles the real case. + .map( + |(consumer_idx, consumer_path, code)| -> NgcResult> { + let info = analyze_module(code, &consumer_path)?; + let mut out: Vec<(usize, String)> = Vec::new(); + for (specifier, imported_names) in &info.local_imports { + let target_path = resolve_local_specifier( + specifier, + &consumer_path, + &all_provider_paths, + local_prefixes, + subpath_ctx, + ) + .or_else(|| specifier_to_path.get(specifier).cloned()); + + let Some(target) = target_path else { + continue; + }; + let Some(&target_idx) = module_to_chunk_idx.get(&target) else { continue; + }; + if target_idx == consumer_idx { + continue; + } + for name in imported_names { + out.push((target_idx, name.clone())); } - local_used.insert(name.clone()); } - } - Ok(local_used) - }) + Ok(out) + }, + ) .collect::>>()?; - let mut used: HashSet = HashSet::new(); - for names in per_consumer { - used.extend(names); + for entries in per_consumer { + for (idx, name) in entries { + if let Some(set) = result.get_mut(idx) { + set.insert(name); + } + } } - // Phase B (serial): namespace-import expansion. Kept serial so the - // `provider_exports` cache parses each provider at most once even when - // several consumers import the same namespace. + // Phase B (serial): namespace-import expansion. Each `import * as X + // from '...'` in a consumer adds every export of the target module to + // the owning chunk's used set. Provider parses are cached so a hot + // namespace import is parsed at most once. let mut provider_exports: HashMap> = HashMap::new(); - for consumer_path in consumer_modules { + for (consumer_idx, consumer_path) in &consumers { let Some(code) = all_code.get(consumer_path) else { continue; }; - expand_namespace_imports( + expand_namespace_imports_per_provider( code, consumer_path, - provider_modules, + *consumer_idx, + &all_provider_paths, + module_to_chunk_idx, + specifier_to_path, local_prefixes, all_code, &mut provider_exports, - &mut used, + &mut result, subpath_ctx, )?; } - Ok(used) + Ok(result) } #[allow(clippy::too_many_arguments)] -fn expand_namespace_imports( +fn expand_namespace_imports_per_provider( code: &str, consumer_path: &Path, + consumer_chunk_idx: usize, provider_modules: &[PathBuf], + module_to_chunk_idx: &HashMap, + specifier_to_path: &HashMap, local_prefixes: &[&str], all_code: &HashMap, provider_exports: &mut HashMap>, - used: &mut HashSet, + per_chunk_used: &mut [HashSet], subpath_ctx: Option>, ) -> NgcResult<()> { let allocator = Allocator::new(); @@ -460,15 +492,22 @@ fn expand_namespace_imports( continue; } let source = import.source.value.to_string(); - let Some(target) = resolve_local_specifier( + let target = resolve_local_specifier( &source, consumer_path, provider_modules, local_prefixes, subpath_ctx, - ) else { + ) + .or_else(|| specifier_to_path.get(&source).cloned()); + let Some(target) = target else { continue }; + let Some(&target_idx) = module_to_chunk_idx.get(&target) else { continue; }; + if target_idx == consumer_chunk_idx { + continue; + } + let exports = match provider_exports.get(&target) { Some(e) => e.clone(), None => { @@ -480,7 +519,9 @@ fn expand_namespace_imports( info.exported_names } }; - used.extend(exports); + if let Some(set) = per_chunk_used.get_mut(target_idx) { + set.extend(exports); + } } Ok(()) @@ -630,11 +671,13 @@ mod tests { } #[test] - fn test_collect_cross_chunk_used_names_dotted_filename() { + fn test_collect_cross_chunk_used_names_per_provider_dotted_filename() { // Regression: resolve_local_specifier previously used `with_extension`, // which treated `.service` as an existing extension and replaced it. // Imports like `./foo.service` then failed to resolve against // `foo.service.ts` and cross-chunk consumption was missed. + use crate::chunk::{Chunk, ChunkGraph, ChunkKind}; + let dir = tempfile::tempdir().expect("create temp dir"); let svc = dir.path().join("analytics.service.ts"); let comp = dir.path().join("comp.ts"); @@ -648,59 +691,173 @@ mod tests { let canon_svc = svc.canonicalize().expect("canon svc"); let canon_comp = comp.canonicalize().expect("canon comp"); - let mut modules = HashMap::new(); - modules.insert( + let mut all_code = HashMap::new(); + all_code.insert( canon_svc.clone(), "export class AnalyticsService {}\n".into(), ); - modules.insert( + all_code.insert( canon_comp.clone(), "import { AnalyticsService } from './analytics.service';\nnew AnalyticsService();\n" .into(), ); - let used = - collect_cross_chunk_used_names(&[canon_comp], &[canon_svc], &modules, &["."], None) - .expect("should collect"); + let chunks = vec![ + Chunk { + kind: ChunkKind::Main, + filename: "main.js".to_string(), + modules: vec![canon_svc.clone()], + entry: canon_svc.clone(), + }, + Chunk { + kind: ChunkKind::Lazy, + filename: "lazy.js".to_string(), + modules: vec![canon_comp.clone()], + entry: canon_comp.clone(), + }, + ]; + let mut module_to_chunk_idx: HashMap = HashMap::new(); + for (idx, chunk) in chunks.iter().enumerate() { + for m in &chunk.modules { + module_to_chunk_idx.insert(m.clone(), idx); + } + } + let chunk_graph = ChunkGraph { + chunks, + dynamic_import_map: HashMap::new(), + module_to_chunk_idx, + }; + + let per_provider = collect_cross_chunk_used_names_per_provider( + &chunk_graph, + &all_code, + &["."], + &HashMap::new(), + None, + ) + .expect("should collect"); assert!( - used.contains("AnalyticsService"), - "import of ./foo.service must resolve to foo.service.ts" + per_provider[0].contains("AnalyticsService"), + "import of ./foo.service must resolve to foo.service.ts: {per_provider:?}" ); } #[test] - fn test_collect_cross_chunk_used_names_named_import() { - // A lazy-chunk module imports AnalyticsService from a main-chunk module; - // collect_cross_chunk_used_names must surface it. Uses a real tempdir - // so resolve_local_specifier's canonicalize step can succeed. + fn test_collect_cross_chunk_used_names_per_provider_multi_chunk() { + // Three chunks: main (chunk 0), lazy (chunk 1) sourced via dynamic + // import, and a vendor chunk (chunk 2) providing an npm-style + // module. The lazy chunk imports one name from main and one name + // from vendor; main imports nothing externally. Per-provider + // result must attribute each import to the correct chunk only. + use crate::chunk::{Chunk, ChunkGraph, ChunkKind}; + let dir = tempfile::tempdir().expect("create temp dir"); - let main_svc = dir.path().join("svc.js"); - let lazy_dir = dir.path().join("lazy"); - std::fs::create_dir_all(&lazy_dir).expect("create lazy dir"); - let lazy_comp = lazy_dir.join("comp.js"); - std::fs::write(&main_svc, "export class AnalyticsService {}\n").expect("write svc"); + let main_path = dir.path().join("main.js"); + let svc_path = dir.path().join("svc.js"); + let lazy_path = dir.path().join("lazy.js"); + let vendor_path = dir.path().join("vendor_pkg.js"); + + std::fs::write(&main_path, "// main entry\n").expect("write main"); + std::fs::write(&svc_path, "export class MainService {}\n").expect("write svc"); std::fs::write( - &lazy_comp, - "import { AnalyticsService } from '../svc';\nnew AnalyticsService();\n", + &lazy_path, + "import { MainService } from './svc';\n\ + import { vendorFn } from 'vendor-pkg';\n\ + new MainService(); vendorFn();\n", ) - .expect("write comp"); + .expect("write lazy"); + std::fs::write( + &vendor_path, + "export const vendorFn = () => 1;\nexport const vendorUnused = () => 2;\n", + ) + .expect("write vendor"); + + let canon_main = main_path.canonicalize().expect("canon main"); + let canon_svc = svc_path.canonicalize().expect("canon svc"); + let canon_lazy = lazy_path.canonicalize().expect("canon lazy"); + let canon_vendor = vendor_path.canonicalize().expect("canon vendor"); + + let mut all_code: HashMap = HashMap::new(); + all_code.insert(canon_main.clone(), "// main entry\n".into()); + all_code.insert(canon_svc.clone(), "export class MainService {}\n".into()); + all_code.insert( + canon_lazy.clone(), + "import { MainService } from './svc';\n\ + import { vendorFn } from 'vendor-pkg';\n\ + new MainService(); vendorFn();\n" + .into(), + ); + all_code.insert( + canon_vendor.clone(), + "export const vendorFn = () => 1;\nexport const vendorUnused = () => 2;\n".into(), + ); + + let chunks = vec![ + Chunk { + kind: ChunkKind::Main, + filename: "main.js".to_string(), + modules: vec![canon_main.clone(), canon_svc.clone()], + entry: canon_main.clone(), + }, + Chunk { + kind: ChunkKind::Lazy, + filename: "lazy.js".to_string(), + modules: vec![canon_lazy.clone()], + entry: canon_lazy.clone(), + }, + Chunk { + kind: ChunkKind::Shared, + filename: "vendor.js".to_string(), + modules: vec![canon_vendor.clone()], + entry: canon_vendor.clone(), + }, + ]; + let mut module_to_chunk_idx: HashMap = HashMap::new(); + for (idx, chunk) in chunks.iter().enumerate() { + for m in &chunk.modules { + module_to_chunk_idx.insert(m.clone(), idx); + } + } + let chunk_graph = ChunkGraph { + chunks, + dynamic_import_map: HashMap::new(), + module_to_chunk_idx, + }; - let canon_svc = main_svc.canonicalize().expect("canon svc"); - let canon_comp = lazy_comp.canonicalize().expect("canon comp"); + let mut specifier_to_path: HashMap = HashMap::new(); + specifier_to_path.insert("vendor-pkg".to_string(), canon_vendor.clone()); - let mut modules = HashMap::new(); - modules.insert( - canon_svc.clone(), - "export class AnalyticsService {}\n".into(), + let per_provider = collect_cross_chunk_used_names_per_provider( + &chunk_graph, + &all_code, + &["."], + &specifier_to_path, + None, + ) + .expect("should collect"); + + assert_eq!(per_provider.len(), 3); + assert!( + per_provider[0].contains("MainService"), + "lazy's import of MainService should land in main's set: {per_provider:?}" ); - modules.insert( - canon_comp.clone(), - "import { AnalyticsService } from '../svc';\nnew AnalyticsService();\n".into(), + assert!( + !per_provider[0].contains("vendorFn"), + "vendorFn must not be attributed to main" + ); + assert!( + per_provider[1].is_empty(), + "no one imports from the lazy chunk; its set must be empty: {:?}", + per_provider[1] + ); + assert!( + per_provider[2].contains("vendorFn"), + "lazy's `import {{ vendorFn }} from 'vendor-pkg'` must land in vendor's set: {per_provider:?}" + ); + assert!( + !per_provider[2].contains("vendorUnused"), + "vendorUnused is never imported — must not be in vendor's set" ); - - let used = - collect_cross_chunk_used_names(&[canon_comp], &[canon_svc], &modules, &["."], None) - .expect("should collect"); - assert!(used.contains("AnalyticsService")); } + } diff --git a/crates/bundler/tests/vendor_chunk_splitting_integration.rs b/crates/bundler/tests/vendor_chunk_splitting_integration.rs index cf1ab6d..55ab766 100644 --- a/crates/bundler/tests/vendor_chunk_splitting_integration.rs +++ b/crates/bundler/tests/vendor_chunk_splitting_integration.rs @@ -262,6 +262,179 @@ fn lazy_only_vendor_chunk_is_not_initial() { ); } +/// Per-provider shake (issue #171): if a vendor chunk's npm module exports +/// both a name some other chunk imports and a name no consumer touches, only +/// the consumed name must survive in the emitted vendor chunk code. Before +/// per-provider shake, vendor chunks pinned every export the package +/// declared because `externally_used` was `None` and the entry-walk reached +/// every name. Now each chunk's tree-shaker gets its own externally-used +/// set computed from cross-chunk imports. +#[test] +fn vendor_chunk_drops_unreferenced_exports() { + let temp = tempdir().expect("create temp dir"); + let root = temp.path(); + + fs::write( + root.join("tsconfig.json"), + r#"{ "include": ["src/**/*.ts"], "exclude": [] }"#, + ) + .expect("write tsconfig"); + + fs::write( + root.join("package.json"), + r#"{ "name": "shake-fixture", "dependencies": { "shake-pkg": "1.0.0" } }"#, + ) + .expect("write package.json"); + + let src = root.join("src"); + fs::create_dir_all(&src).expect("create src"); + fs::write( + src.join("main.ts"), + "function loadA(){return import('./route-a');}\n\ + function loadB(){return import('./route-b');}\n\ + console.log(loadA, loadB);\n", + ) + .expect("write main.ts"); + // Both lazy routes import only `usedSentinel` from the npm package. + // `unusedSentinel` has no consumer anywhere in the bundle. + fs::write( + src.join("route-a.ts"), + "import { usedSentinel } from 'shake-pkg';\n\ + export const A = () => usedSentinel('a');\n", + ) + .expect("write route-a.ts"); + fs::write( + src.join("route-b.ts"), + "import { usedSentinel } from 'shake-pkg';\n\ + export const B = () => usedSentinel('b');\n", + ) + .expect("write route-b.ts"); + + // The npm package is split: a re-export entry plus an implementation + // file. Lexicographic order picks `a-entry.mjs` as the chunk entry, so + // `impl.js`'s declarations are subject to per-provider shake (not + // pinned by the entry-always-kept rule that protects entry's own + // export names). + let pkg_dir = root.join("node_modules").join("shake-pkg"); + fs::create_dir_all(&pkg_dir).expect("create pkg dir"); + fs::write( + pkg_dir.join("package.json"), + r#"{ "name": "shake-pkg", "version": "1.0.0", "main": "a-entry.mjs" }"#, + ) + .expect("write pkg package.json"); + fs::write( + pkg_dir.join("a-entry.mjs"), + "export { usedSentinel, unusedSentinel } from './impl';\n", + ) + .expect("write a-entry.mjs"); + fs::write( + pkg_dir.join("impl.js"), + "export const usedSentinel = (x) => `USED_SENTINEL:${x}`;\n\ + export const unusedSentinel = (x) => `UNUSED_SENTINEL:${x}`;\n", + ) + .expect("write impl.js"); + + let file_graph = resolve_project(&root.join("tsconfig.json")).expect("resolve project"); + let entry = file_graph + .entry_points + .iter() + .find(|p| p.file_name().is_some_and(|n| n == "main.ts")) + .cloned() + .expect("main.ts entry"); + let bare_specs: Vec = file_graph.npm_import_sites.keys().cloned().collect(); + let npm = resolve_npm_dependencies(&bare_specs, root, DEVELOPMENT_BROWSER_CONDITIONS) + .expect("npm resolution"); + + let mut graph = file_graph.graph; + let mut path_index = file_graph.path_index; + for path in npm.modules.keys() { + if !path_index.contains_key(path) { + let idx = graph.add_node(path.clone()); + path_index.insert(path.clone(), idx); + } + } + for (spec, sites) in &file_graph.npm_import_sites { + if let Some(target_path) = npm + .modules + .keys() + .find(|p| p.to_string_lossy().contains(&format!("/{spec}/a-entry.mjs"))) + { + let to_idx = path_index[target_path]; + for (from_file, kind) in sites { + if let Some(&from_idx) = path_index.get(from_file) { + graph.add_edge(from_idx, to_idx, *kind); + } + } + } + } + // Wire the re-export edge a-entry.mjs -> impl.js so chunk graph keeps + // them in the same vendor partition. + let entry_path = npm + .modules + .keys() + .find(|p| p.to_string_lossy().ends_with("/a-entry.mjs")) + .cloned() + .expect("a-entry.mjs in npm.modules"); + let impl_path = npm + .modules + .keys() + .find(|p| p.to_string_lossy().ends_with("/impl.js")) + .cloned() + .expect("impl.js in npm.modules"); + graph.add_edge( + path_index[&entry_path], + path_index[&impl_path], + ngc_project_resolver::ImportKind::Static, + ); + + let mut modules: HashMap = HashMap::new(); + for idx in graph.node_indices() { + let path = &graph[idx]; + let source = npm + .modules + .get(path) + .cloned() + .or_else(|| fs::read_to_string(path).ok()) + .unwrap_or_else(|| panic!("source missing for {}", path.display())); + modules.insert(path.clone(), source); + } + + let input = BundleInput { + modules, + graph, + entry, + local_prefixes: vec![".".to_string()], + root_dir: root.to_path_buf(), + options: BundleOptions { + tree_shake: true, + ..BundleOptions::default() + }, + per_module_maps: HashMap::new(), + bundled_specifiers: npm.resolved_specifiers.clone(), + export_conditions: Vec::new(), + external_specifiers: Default::default(), + }; + + let output = bundle(&input).expect("bundle succeeds"); + + let vendor_name = output + .chunk_kinds + .iter() + .find(|(_, k)| **k == ChunkKind::Shared) + .map(|(n, _)| n.clone()) + .expect("vendor chunk"); + let vendor_code = &output.chunks[&vendor_name]; + + assert!( + vendor_code.contains("USED_SENTINEL"), + "used export must survive in vendor chunk: {vendor_code}" + ); + assert!( + !vendor_code.contains("UNUSED_SENTINEL"), + "unreferenced export must be tree-shaken from vendor chunk: {vendor_code}" + ); +} + /// Determinism: bundling the same input twice produces byte-identical chunk /// filenames + content. Vendor naming hashes absolute module paths, so we /// build twice in the same temp directory (real builds have a stable project From 05b30d0e654f858e5fbda34843e9a3f9f86e15f9 Mon Sep 17 00:00:00 2001 From: lukekania Date: Mon, 18 May 2026 10:51:29 +0200 Subject: [PATCH 07/20] chore: cargo fmt --- crates/bundler/src/npm_wrap.rs | 7 ++++--- crates/bundler/src/shake.rs | 1 - .../bundler/tests/vendor_chunk_splitting_integration.rs | 9 ++++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/bundler/src/npm_wrap.rs b/crates/bundler/src/npm_wrap.rs index 64b6a04..b343931 100644 --- a/crates/bundler/src/npm_wrap.rs +++ b/crates/bundler/src/npm_wrap.rs @@ -539,9 +539,10 @@ mod tests { }; let mut unused: HashSet = HashSet::new(); unused.insert("unused".to_string()); - let result = - wrap_npm_module(code, "test.js", "__ns_test", Some(&unused), resolve).unwrap(); - assert!(result.wrapped_code.contains("__exports.used = __ns_impl.used")); + let result = wrap_npm_module(code, "test.js", "__ns_test", Some(&unused), resolve).unwrap(); + assert!(result + .wrapped_code + .contains("__exports.used = __ns_impl.used")); assert!(!result.wrapped_code.contains("__exports.unused")); } diff --git a/crates/bundler/src/shake.rs b/crates/bundler/src/shake.rs index 239e76a..e2d4708 100644 --- a/crates/bundler/src/shake.rs +++ b/crates/bundler/src/shake.rs @@ -859,5 +859,4 @@ mod tests { "vendorUnused is never imported — must not be in vendor's set" ); } - } diff --git a/crates/bundler/tests/vendor_chunk_splitting_integration.rs b/crates/bundler/tests/vendor_chunk_splitting_integration.rs index 55ab766..b7a9b39 100644 --- a/crates/bundler/tests/vendor_chunk_splitting_integration.rs +++ b/crates/bundler/tests/vendor_chunk_splitting_integration.rs @@ -354,11 +354,10 @@ fn vendor_chunk_drops_unreferenced_exports() { } } for (spec, sites) in &file_graph.npm_import_sites { - if let Some(target_path) = npm - .modules - .keys() - .find(|p| p.to_string_lossy().contains(&format!("/{spec}/a-entry.mjs"))) - { + if let Some(target_path) = npm.modules.keys().find(|p| { + p.to_string_lossy() + .contains(&format!("/{spec}/a-entry.mjs")) + }) { let to_idx = path_index[target_path]; for (from_file, kind) in sites { if let Some(&from_idx) = path_index.get(from_file) { From f3781b20c6754353ca8c1586de1b45e9d3f4c525 Mon Sep 17 00:00:00 2001 From: lukekania Date: Wed, 20 May 2026 12:37:01 +0200 Subject: [PATCH 08/20] feat(ngsw): per-locale ngsw.json manifests when --localize is set (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously a localized build with serviceWorker enabled skipped ngsw.json generation entirely, forcing apps to choose between i18n and PWA caching. Move the service-worker step to run after locale fan-out and, when --localize is in use, emit one ngsw.json per // deploy root. Asset hashes are computed from each locale's own tree, so a translated bundle hashes differently per locale — correct cache invalidation. The non-localized path is unchanged. Bump version to 0.10.14. --- Cargo.lock | 20 ++-- Cargo.toml | 2 +- crates/cli/src/main.rs | 243 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 237 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6bed691..72f4222 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,7 +730,7 @@ dependencies = [ [[package]] name = "ngc-bundler" -version = "0.10.13" +version = "0.10.14" dependencies = [ "dashmap", "ngc-diagnostics", @@ -755,7 +755,7 @@ dependencies = [ [[package]] name = "ngc-dev-server" -version = "0.10.13" +version = "0.10.14" dependencies = [ "ngc-diagnostics", "serde_json", @@ -766,7 +766,7 @@ dependencies = [ [[package]] name = "ngc-diagnostics" -version = "0.10.13" +version = "0.10.14" dependencies = [ "serde_json", "thiserror", @@ -774,7 +774,7 @@ dependencies = [ [[package]] name = "ngc-linker" -version = "0.10.13" +version = "0.10.14" dependencies = [ "dashmap", "insta", @@ -792,7 +792,7 @@ dependencies = [ [[package]] name = "ngc-npm-resolver" -version = "0.10.13" +version = "0.10.14" dependencies = [ "dashmap", "ngc-diagnostics", @@ -807,7 +807,7 @@ dependencies = [ [[package]] name = "ngc-project-resolver" -version = "0.10.13" +version = "0.10.14" dependencies = [ "dashmap", "glob", @@ -823,7 +823,7 @@ dependencies = [ [[package]] name = "ngc-rs" -version = "0.10.13" +version = "0.10.14" dependencies = [ "base64", "clap", @@ -857,7 +857,7 @@ dependencies = [ [[package]] name = "ngc-template-compiler" -version = "0.10.13" +version = "0.10.14" dependencies = [ "insta", "ngc-diagnostics", @@ -879,7 +879,7 @@ dependencies = [ [[package]] name = "ngc-ts-transform" -version = "0.10.13" +version = "0.10.14" dependencies = [ "ngc-diagnostics", "oxc_allocator", @@ -898,7 +898,7 @@ dependencies = [ [[package]] name = "ngc-watch" -version = "0.10.13" +version = "0.10.14" dependencies = [ "ngc-diagnostics", "notify", diff --git a/Cargo.toml b/Cargo.toml index 44371e9..f5ae044 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.13" +version = "0.10.14" 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 922e4bc..8f76733 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1243,27 +1243,12 @@ pub(crate) fn run_build_with_options( output_files.push(lp); } - // Step 12.5: Service worker manifest (`ngsw.json`) when the project opts - // in via `architect.build.options.serviceWorker`. Hashing runs *after* - // every other writer so it sees the final filenames + contents. - if let Some(ref ap) = angular_project { - if ap.service_worker { - if localize.is_some() { - tracing::warn!( - "serviceWorker is enabled but --localize was passed; skipping ngsw.json (per-locale manifests are not yet supported)" - ); - } else { - let ngsw_paths = generate_service_worker(ap, &out_dir, &config_dir)?; - output_files.extend(ngsw_paths); - } - } - } - - // Step 13: --localize → fan the source-locale build out to + // Step 12.5: --localize → fan the source-locale build out to // `//` and produce a translated copy under // `//` for each entry in `i18n.locales`. A non-empty // `subset` filters the emitted locales — useful for trimming CI builds // that only need one or two locales per deploy. + let localized = localize.is_some(); if let Some(subset) = localize { let i18n = angular_project .as_ref() @@ -1277,6 +1262,44 @@ pub(crate) fn run_build_with_options( output_files = localized_files; } + // Step 13: Service worker manifest (`ngsw.json`) when the project opts in + // via `architect.build.options.serviceWorker`. Hashing runs *after* every + // other writer (including locale fan-out) so it sees the final filenames + + // contents. With `--localize` each locale subdirectory is its own PWA + // deploy root, so we emit one manifest per `//` — asset + // hashes are naturally per-locale (translated bundles differ byte-for-byte). + if let Some(ref ap) = angular_project { + if ap.service_worker { + if localized { + for entry in std::fs::read_dir(&out_dir).map_err(|e| NgcError::Io { + path: out_dir.clone(), + source: e, + })? { + let entry = entry.map_err(|e| NgcError::Io { + path: out_dir.clone(), + source: e, + })?; + let locale_dir = entry.path(); + let is_dir = entry + .file_type() + .map_err(|e| NgcError::Io { + path: locale_dir.clone(), + source: e, + })? + .is_dir(); + if !is_dir { + continue; + } + let ngsw_paths = generate_service_worker(ap, &locale_dir, &config_dir)?; + output_files.extend(ngsw_paths); + } + } else { + let ngsw_paths = generate_service_worker(ap, &out_dir, &config_dir)?; + output_files.extend(ngsw_paths); + } + } + } + // Stat each written path and tag it with its OutputKind. Failed stats // (e.g. file removed mid-build) report size 0 rather than aborting — // matches the prior behaviour of silently dropping such entries from @@ -3582,4 +3605,190 @@ mod tests { assert!(table.contains_key(*url), "missing hash for {url}"); } } + + /// End-to-end fixture: a localized build (`--localize`) with + /// `serviceWorker: true` must emit one `ngsw.json` per locale + /// subdirectory, each carrying deploy-root-relative URLs and asset + /// hashes computed from that locale's own (translated) tree. + #[test] + fn test_service_worker_pipeline_localized_fixture() { + use ngc_project_resolver::angular_json::resolve_angular_project; + use sha1::{Digest, Sha1}; + + let dir = tempfile::tempdir().expect("create temp dir"); + let root = dir.path(); + + // angular.json: serviceWorker on + an i18n block declaring one + // translation locale (`fr`) alongside the `en` source locale. + std::fs::write( + root.join("angular.json"), + r#"{ + "projects": { + "pwa": { + "root": "", + "sourceRoot": "src", + "i18n": { + "sourceLocale": "en", + "locales": { "fr": "src/locale/messages.fr.xlf" } + }, + "architect": { + "build": { + "options": { + "outputPath": "dist/pwa", + "tsConfig": "tsconfig.json", + "serviceWorker": true, + "ngswConfigPath": "ngsw-config.json" + } + } + } + } + } + }"#, + ) + .unwrap(); + + std::fs::write( + root.join("ngsw-config.json"), + r#"{ + "index": "/index.html", + "assetGroups": [ + { + "name": "app", + "installMode": "prefetch", + "resources": { "files": ["/index.html", "/*.js", "/*.css"] } + } + ] + }"#, + ) + .unwrap(); + + // Translation file mapping the `greeting` message to French so the + // `fr` bundle diverges byte-for-byte from `en` — the precise reason + // each locale needs its own manifest with its own hashes. + std::fs::create_dir_all(root.join("src").join("locale")).unwrap(); + std::fs::write( + root.join("src").join("locale").join("messages.fr.xlf"), + r#" + + + + + Hello + Bonjour + + + +"#, + ) + .unwrap(); + + // Pre-populate the flat source-locale dist tree, as the bundler would. + let dist = root.join("dist").join("pwa"); + std::fs::create_dir_all(&dist).unwrap(); + let index_bytes = b"pwa".to_vec(); + let style_bytes = b"body { color: red; }".to_vec(); + std::fs::write(dist.join("index.html"), &index_bytes).unwrap(); + std::fs::write( + dist.join("main-ABCDE.js"), + "var x = $localize`:@@greeting:Hello`;", + ) + .unwrap(); + std::fs::write(dist.join("styles-FGHIJ.css"), &style_bytes).unwrap(); + let original_files = vec![ + dist.join("index.html"), + dist.join("main-ABCDE.js"), + dist.join("styles-FGHIJ.css"), + ]; + + let project = resolve_angular_project(&root.join("angular.json"), Some("pwa"), None) + .expect("resolve angular project"); + assert!(project.service_worker); + let i18n = project.i18n.as_ref().expect("i18n block parsed"); + + // Fan the flat build out into `dist/pwa/en/` and `dist/pwa/fr/`. + fan_out_locales(&dist, i18n, &[], &original_files).expect("fan_out_locales"); + + // Generate one manifest per locale subdir — mirrors the build pipeline. + for locale in ["en", "fr"] { + let paths = generate_service_worker(&project, &dist.join(locale), root) + .expect("generate_service_worker"); + assert!( + !paths.is_empty(), + "{locale}: should write at least ngsw.json" + ); + } + + let expect_sha1 = |bytes: &[u8]| -> String { + let mut h = Sha1::new(); + h.update(bytes); + h.finalize().iter().fold(String::new(), |mut acc, b| { + acc.push_str(&format!("{b:02x}")); + acc + }) + }; + + for locale in ["en", "fr"] { + let manifest_path = dist.join(locale).join("ngsw.json"); + assert!(manifest_path.is_file(), "{locale}/ngsw.json must exist"); + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()) + .expect("ngsw.json parses as JSON"); + + let groups = manifest["assetGroups"].as_array().expect("assetGroups"); + let urls: Vec<&str> = groups[0]["urls"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + // URLs are relative to the locale deploy root — no `/en/` prefix. + assert!( + urls.contains(&"/index.html"), + "{locale}: missing /index.html" + ); + assert!( + urls.contains(&"/main-ABCDE.js"), + "{locale}: missing /main-ABCDE.js" + ); + assert!( + urls.contains(&"/styles-FGHIJ.css"), + "{locale}: missing /styles-FGHIJ.css" + ); + assert!( + !urls.iter().any(|u| u.starts_with(&format!("/{locale}/"))), + "{locale}: URLs must be deploy-root-relative, got {urls:?}" + ); + + // hashTable entries must equal the SHA-1 of this locale's bytes. + let table = manifest["hashTable"].as_object().expect("hashTable"); + let main_bytes = std::fs::read(dist.join(locale).join("main-ABCDE.js")).unwrap(); + assert_eq!( + table["/main-ABCDE.js"].as_str().unwrap(), + expect_sha1(&main_bytes), + "{locale}: main.js hash must match its own tree" + ); + assert_eq!( + table["/index.html"].as_str().unwrap(), + expect_sha1(&index_bytes) + ); + } + + // The translated `fr` bundle differs from `en`, so the two manifests + // must record different hashes for the same URL — correct cache busting. + let main_hash = |locale: &str| -> String { + let m: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(dist.join(locale).join("ngsw.json")).unwrap(), + ) + .unwrap(); + m["hashTable"]["/main-ABCDE.js"] + .as_str() + .unwrap() + .to_string() + }; + assert_ne!( + main_hash("en"), + main_hash("fr"), + "translated bundle must hash differently per locale" + ); + } } From 7fb42330428bc000e342cf09381e1c69e176ae1f Mon Sep 17 00:00:00 2001 From: lukekania Date: Fri, 22 May 2026 10:46:02 +0200 Subject: [PATCH 09/20] feat(dev-server): support `headers` custom HTTP response headers (#143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev-server `headers` option in angular.json was silently dropped, so apps relying on production-like security headers in dev (CSP, COOP) or testing CORS scenarios had no way to configure them. - `dev-server.json`: add `headers` (object of name to string value). - `serve/options.ts`: serialize the map into a `--headers` JSON arg, trimming names and dropping empty-name / non-string entries. - `serve_cmd.rs` / `main.rs`: parse the `--headers` JSON object and thread the name/value pairs into `DevServerConfig`. - `dev-server`: new `CustomHeaders` type applies the configured headers to every served response — static assets, the SPA-fallback index.html, and the SSE live-reload stream. Headers the server sets itself (`Content-Type`, `Cache-Control`, and the SSE-specific `Connection` / `Access-Control-Allow-Origin`) are never overridden. Headers are applied only in the Rust dev server, so proxy-forwarded responses keep their upstream headers untouched. Bump version to 0.10.15. --- Cargo.lock | 20 +- Cargo.toml | 2 +- crates/cli/src/main.rs | 89 +++++++ crates/cli/src/serve_cmd.rs | 7 +- crates/dev-server/src/lib.rs | 234 ++++++++++++++++-- crates/dev-server/tests/integration.rs | 159 +++++++++++- packages/builder/schemas/dev-server.json | 5 + .../src/serve/__tests__/options.test.ts | 54 ++++ packages/builder/src/serve/options.ts | 33 +++ 9 files changed, 559 insertions(+), 44 deletions(-) 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; From 46c77ec8d351e92215116c9ca9586a6c55d2143f Mon Sep 17 00:00:00 2001 From: lukekania Date: Mon, 8 Jun 2026 15:38:35 +0200 Subject: [PATCH 10/20] feat(dev-server): support ssl/sslKey/sslCert for HTTPS dev (#142) The dev-server builder hard-failed whenever ssl/sslKey/sslCert was set, so projects needing HTTPS in dev (OAuth callbacks, secure-cookie testing, mixed-content debugging, service-worker registration) had to stand up a separate TLS-terminating proxy. ngc-rs now serves HTTPS directly. - dev-server: new TlsConfig (from_pem for explicit material, self_signed via rcgen for the auto-generated case, covering the bind host plus the loopback names). DevServerConfig::with_tls wires the PEM into tiny_http's ssl-rustls backend; the SSE live-reload stream rides the same TLS connection. DevServer::scheme() reports https/http. The private key is redacted from Debug output. - cli: --ssl/--ssl-key/--ssl-cert on serve. resolve_tls enforces both-or- neither cert paths and that key/cert require --ssl; the printed URL uses the right scheme. - builder: options.ts forwards the flags, resolves cert paths against the workspace root, emits an https:// URL, and rejects ssl + proxyConfig (the proxy is the browser-facing endpoint; Node-side TLS termination is out of scope). Schema descriptions updated; README serve section corrected. ssl: true without explicit key/cert mints a throwaway self-signed certificate, matching @angular/build:dev-server; browsers show the usual untrusted-cert warning. Bump version to 0.10.16. --- Cargo.lock | 584 +++++++++++++++++- Cargo.toml | 2 +- README.md | 7 +- crates/cli/src/main.rs | 23 + crates/cli/src/serve_cmd.rs | 122 +++- crates/dev-server/Cargo.toml | 5 +- crates/dev-server/src/lib.rs | 155 ++++- crates/dev-server/tests/integration.rs | 202 ++++++ packages/builder/schemas/dev-server.json | 6 +- .../src/serve/__tests__/options.test.ts | 53 +- packages/builder/src/serve/options.ts | 65 +- 11 files changed, 1172 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9324fa0..25bd796 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,12 +85,57 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -107,6 +152,15 @@ dependencies = [ "vsimd", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -147,6 +201,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "castaway" version = "0.2.4" @@ -156,6 +216,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -347,6 +417,35 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -369,6 +468,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dragonbox_ecma" version = "0.1.12" @@ -432,6 +542,12 @@ dependencies = [ "libredox", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -473,6 +589,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -614,6 +741,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "json-escape-simd" version = "3.0.1" @@ -706,6 +844,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -730,7 +874,7 @@ dependencies = [ [[package]] name = "ngc-bundler" -version = "0.10.15" +version = "0.10.16" dependencies = [ "dashmap", "ngc-diagnostics", @@ -755,9 +899,11 @@ dependencies = [ [[package]] name = "ngc-dev-server" -version = "0.10.15" +version = "0.10.16" dependencies = [ "ngc-diagnostics", + "rcgen", + "rustls", "serde_json", "tempfile", "tiny_http", @@ -766,7 +912,7 @@ dependencies = [ [[package]] name = "ngc-diagnostics" -version = "0.10.15" +version = "0.10.16" dependencies = [ "serde_json", "thiserror", @@ -774,7 +920,7 @@ dependencies = [ [[package]] name = "ngc-linker" -version = "0.10.15" +version = "0.10.16" dependencies = [ "dashmap", "insta", @@ -792,7 +938,7 @@ dependencies = [ [[package]] name = "ngc-npm-resolver" -version = "0.10.15" +version = "0.10.16" dependencies = [ "dashmap", "ngc-diagnostics", @@ -807,7 +953,7 @@ dependencies = [ [[package]] name = "ngc-project-resolver" -version = "0.10.15" +version = "0.10.16" dependencies = [ "dashmap", "glob", @@ -823,9 +969,9 @@ dependencies = [ [[package]] name = "ngc-rs" -version = "0.10.15" +version = "0.10.16" dependencies = [ - "base64", + "base64 0.22.1", "clap", "colored", "ctrlc", @@ -857,7 +1003,7 @@ dependencies = [ [[package]] name = "ngc-template-compiler" -version = "0.10.15" +version = "0.10.16" dependencies = [ "insta", "ngc-diagnostics", @@ -879,7 +1025,7 @@ dependencies = [ [[package]] name = "ngc-ts-transform" -version = "0.10.15" +version = "0.10.16" dependencies = [ "ngc-diagnostics", "oxc_allocator", @@ -898,7 +1044,7 @@ dependencies = [ [[package]] name = "ngc-watch" -version = "0.10.15" +version = "0.10.16" dependencies = [ "ngc-diagnostics", "notify", @@ -918,6 +1064,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nonmax" version = "0.5.5" @@ -961,6 +1117,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -994,6 +1156,15 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1362,7 +1533,7 @@ version = "0.122.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a216c0a1291fcb42f6be51ce32d928921cf2a6e232e43e6339c8e48d0e4048f" dependencies = [ - "base64", + "base64 0.22.1", "compact_str", "indexmap", "itoa", @@ -1417,6 +1588,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1545,6 +1726,12 @@ dependencies = [ "serde", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "prettyplease" version = "0.2.37" @@ -1599,6 +1786,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring 0.17.14", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1646,6 +1847,35 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted 0.7.1", + "web-sys", + "winapi", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + [[package]] name = "ropey" version = "1.6.1" @@ -1662,6 +1892,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1675,6 +1914,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" +dependencies = [ + "log", + "ring 0.16.20", + "sct", + "webpki", +] + +[[package]] +name = "rustls-pemfile" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eebeaeb360c87bfb72e84abdb3447159c0eaececf1bef2aecd65a8be949d1c9" +dependencies = [ + "base64 0.13.1", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1702,6 +1971,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "self_cell" version = "1.2.2" @@ -1795,6 +2074,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -1825,6 +2110,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1854,6 +2145,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1861,7 +2163,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1907,6 +2209,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny_http" version = "0.12.0" @@ -1917,6 +2250,9 @@ dependencies = [ "chunked_transfer", "httpdate", "log", + "rustls", + "rustls-pemfile", + "zeroize", ] [[package]] @@ -2028,6 +2364,18 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2086,6 +2434,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2120,6 +2513,26 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "which" version = "8.0.2" @@ -2129,6 +2542,22 @@ dependencies = [ "libc", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2138,6 +2567,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -2150,7 +2585,16 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -2168,13 +2612,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -2183,42 +2643,90 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2307,6 +2815,40 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring 0.17.14", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 4ca96ca..b76a437 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.15" +version = "0.10.16" edition = "2021" license = "MIT OR Apache-2.0" authors = ["lukekania"] diff --git a/README.md b/README.md index 5b863e3..c530ce6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 3abff63..44c41cc 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -262,6 +262,23 @@ enum Commands { /// overridden by these. #[arg(long = "headers")] headers: Option, + /// 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, + /// 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, }, /// Extract translatable messages from every component template in the /// project and emit a translation file (XLIFF 2.0 by default; XLIFF 1.2 @@ -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, @@ -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); diff --git a/crates/cli/src/serve_cmd.rs b/crates/cli/src/serve_cmd.rs index 7ea2119..f8465d7 100644 --- a/crates/cli/src/serve_cmd.rs +++ b/crates/cli/src/serve_cmd.rs @@ -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}; @@ -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, @@ -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> { + 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`] without touching the real signal @@ -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), ) -> 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(); @@ -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!( "{} {}", @@ -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 { diff --git a/crates/dev-server/Cargo.toml b/crates/dev-server/Cargo.toml index 5af3598..cbb1642 100644 --- a/crates/dev-server/Cargo.toml +++ b/crates/dev-server/Cargo.toml @@ -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" diff --git a/crates/dev-server/src/lib.rs b/crates/dev-server/src/lib.rs index 65b6b35..890af2d 100644 --- a/crates/dev-server/src/lib.rs +++ b/crates/dev-server/src/lib.rs @@ -35,7 +35,7 @@ use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; use ngc_diagnostics::{NgcError, NgcResult}; -use tiny_http::{Header, Method, Response, Server, StatusCode}; +use tiny_http::{Header, Method, Response, Server, SslConfig, StatusCode}; /// An event the dev server fans out to connected browsers over SSE. /// @@ -102,6 +102,11 @@ pub struct DevServerConfig { /// `headers` option. Header names the server sets itself are never /// overridden by these — see [`CustomHeaders`]. pub headers: Vec<(String, String)>, + /// TLS material to serve over HTTPS. When `None` (the default) the + /// server speaks plain HTTP. When `Some`, every connection — including + /// the long-lived SSE live-reload stream — is wrapped in TLS. Mirrors + /// `@angular/build:dev-server`'s `ssl`/`sslKey`/`sslCert` options. + pub tls: Option, } impl DevServerConfig { @@ -115,6 +120,7 @@ impl DevServerConfig { serve_path: None, allowed_hosts: Vec::new(), headers: Vec::new(), + tls: None, } } @@ -164,6 +170,86 @@ impl DevServerConfig { .collect(); self } + + /// Serve over HTTPS using the supplied [`TlsConfig`]. Passing `None` + /// (the default) keeps the server on plain HTTP. + pub fn with_tls(mut self, tls: Option) -> Self { + self.tls = tls; + self + } +} + +/// PEM-encoded TLS material used to serve the dev server over HTTPS. +/// +/// Construct one either from caller-supplied certificate and key files +/// ([`TlsConfig::from_pem`]) or by minting a throwaway self-signed +/// certificate for local development ([`TlsConfig::self_signed`]). The bytes +/// are handed to `tiny_http`'s `ssl-rustls` backend, which performs the TLS +/// handshake for every accepted connection. +#[derive(Clone)] +pub struct TlsConfig { + /// PEM-encoded certificate (chain). + cert_pem: Vec, + /// PEM-encoded private key. + key_pem: Vec, +} + +impl TlsConfig { + /// Wrap caller-supplied PEM bytes (e.g. read from `sslCert`/`sslKey` + /// files) without inspecting them — `tiny_http` validates the material + /// when the server is created and surfaces a clear error if either is + /// malformed. + pub fn from_pem(cert_pem: Vec, key_pem: Vec) -> Self { + Self { cert_pem, key_pem } + } + + /// Generate a throwaway self-signed certificate covering `hosts` plus the + /// loopback names (`localhost`, `127.0.0.1`, `::1`), matching what + /// `@angular/build:dev-server` does when `ssl: true` is set without an + /// explicit key/cert. Browsers will show the usual "untrusted + /// certificate" warning the first time. + /// + /// Each host string is added as an IP SAN when it parses as an IP + /// address and a DNS SAN otherwise, so `--host 192.168.1.10` produces a + /// certificate the browser accepts for that address. + pub fn self_signed(hosts: &[String]) -> NgcResult { + let mut sans: Vec = vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + "::1".to_string(), + ]; + for host in hosts { + let trimmed = host.trim(); + // Skip blanks and wildcard binds — `0.0.0.0`/`::` are never a + // hostname the browser connects to, and the loopback SANs above + // already cover local development. + if trimmed.is_empty() || matches!(trimmed, "0.0.0.0" | "::" | "[::]") { + continue; + } + let normalized = trimmed.trim_start_matches('[').trim_end_matches(']'); + if !sans.iter().any(|s| s == normalized) { + sans.push(normalized.to_string()); + } + } + let cert = rcgen::generate_simple_self_signed(sans).map_err(|e| NgcError::ServeError { + message: format!("could not generate self-signed certificate: {e}"), + })?; + Ok(Self { + cert_pem: cert.cert.pem().into_bytes(), + key_pem: cert.signing_key.serialize_pem().into_bytes(), + }) + } +} + +// Hand-written so the private key never lands in a `Debug` dump (e.g. when +// `DevServerConfig` is logged). +impl std::fmt::Debug for TlsConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TlsConfig") + .field("cert_pem", &format_args!("{} bytes", self.cert_pem.len())) + .field("key_pem", &"") + .finish() + } } /// Normalize a `servePath` string to the canonical `/foo/` form. @@ -417,6 +503,7 @@ pub struct DevServer { server: Arc, accept_join: Option>, serve_path: Option, + is_tls: bool, } impl DevServer { @@ -449,9 +536,15 @@ impl DevServer { message: format!("could not read local address: {e}"), })?; - let server = Server::from_listener(listener, None).map_err(|e| NgcError::ServeError { - message: format!("tiny_http server init failed: {e}"), - })?; + let is_tls = config.tls.is_some(); + let ssl_config = config.tls.as_ref().map(|t| SslConfig { + certificate: t.cert_pem.clone(), + private_key: t.key_pem.clone(), + }); + let server = + Server::from_listener(listener, ssl_config).map_err(|e| NgcError::ServeError { + message: format!("tiny_http server init failed: {e}"), + })?; let server = Arc::new(server); let clients: SseClients = Arc::new(Mutex::new(Vec::new())); @@ -492,6 +585,7 @@ impl DevServer { server, accept_join: Some(join), serve_path, + is_tls, }) } @@ -507,6 +601,17 @@ impl DevServer { self.serve_path.as_deref() } + /// The URL scheme the server answers on: `"https"` when TLS is enabled, + /// `"http"` otherwise. Use this to build a browser-facing URL that + /// matches the wire protocol. + pub fn scheme(&self) -> &'static str { + if self.is_tls { + "https" + } else { + "http" + } + } + /// Send a reload event to all connected browsers without going through /// an external channel. Convenient for tests and ad-hoc tooling. pub fn trigger_reload(&self) -> NgcResult<()> { @@ -1483,4 +1588,46 @@ mod tests { assert_eq!(cfg.headers.len(), 2); assert_eq!(cfg.headers[0], ("X-A".to_string(), "1".to_string())); } + + #[test] + fn devserver_config_tls_defaults_to_none() { + assert!(DevServerConfig::new("/tmp/dist").tls.is_none()); + } + + #[test] + fn devserver_config_with_tls_stores_material() { + let tls = TlsConfig::from_pem(b"CERT".to_vec(), b"KEY".to_vec()); + let cfg = DevServerConfig::new("/tmp/dist").with_tls(Some(tls)); + let stored = cfg.tls.expect("tls present"); + assert_eq!(stored.cert_pem, b"CERT"); + assert_eq!(stored.key_pem, b"KEY"); + } + + #[test] + fn tls_self_signed_emits_pem_for_cert_and_key() { + let tls = TlsConfig::self_signed(&["app.local".to_string()]).expect("generate"); + let cert = String::from_utf8(tls.cert_pem.clone()).expect("utf8 cert"); + let key = String::from_utf8(tls.key_pem.clone()).expect("utf8 key"); + assert!(cert.contains("BEGIN CERTIFICATE")); + assert!(cert.contains("END CERTIFICATE")); + assert!(key.contains("PRIVATE KEY")); + } + + #[test] + fn tls_self_signed_skips_blank_and_wildcard_hosts() { + // Should not error on wildcard/blank binds — they're dropped and the + // loopback SANs still cover local development. + let tls = + TlsConfig::self_signed(&["0.0.0.0".to_string(), "".to_string(), "::".to_string()]) + .expect("generate"); + assert!(!tls.cert_pem.is_empty()); + } + + #[test] + fn tls_config_debug_redacts_private_key() { + let tls = TlsConfig::from_pem(b"CERTBYTES".to_vec(), b"SECRETKEY".to_vec()); + let rendered = format!("{tls:?}"); + assert!(rendered.contains("redacted")); + assert!(!rendered.contains("SECRETKEY")); + } } diff --git a/crates/dev-server/tests/integration.rs b/crates/dev-server/tests/integration.rs index 3f26f14..fa3ac40 100644 --- a/crates/dev-server/tests/integration.rs +++ b/crates/dev-server/tests/integration.rs @@ -720,3 +720,205 @@ fn custom_headers_are_emitted_on_the_sse_stream() { } assert!(saw_header, "custom header missing from SSE response head"); } + +// ---------------------------------------------------------------------------- +// HTTPS / TLS (#142) +// +// These tests stand up a dev server with a throwaway self-signed certificate +// and drive it through a rustls client that skips certificate verification — +// the equivalent of clicking through the browser's untrusted-certificate +// warning. They confirm both ordinary static serving and the long-lived SSE +// live-reload stream work once the connection is wrapped in TLS. +// ---------------------------------------------------------------------------- + +mod tls { + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::TcpStream; + use std::sync::mpsc::channel; + use std::sync::Arc; + use std::time::Duration; + + use ngc_dev_server::{ + DevServer, DevServerConfig, DevServerEvent, TlsConfig, LIVE_RELOAD_SCRIPT, + }; + use rustls::{ClientConfig, ClientConnection, StreamOwned}; + use tempfile::TempDir; + + struct TlsFixture { + server: DevServer, + _root: TempDir, + } + + impl TlsFixture { + fn new() -> Self { + let root = TempDir::new().expect("tempdir"); + std::fs::write( + root.path().join("index.html"), + b"

secure

", + ) + .expect("write index"); + let tls = TlsConfig::self_signed(&["127.0.0.1".to_string()]).expect("self-signed"); + let cfg = DevServerConfig::new(root.path()) + .with_port(0) + .with_tls(Some(tls)); + let (_tx, rx) = channel::(); + let server = DevServer::start(cfg, rx).expect("start tls dev server"); + Self { + server, + _root: root, + } + } + } + + // A certificate verifier that accepts everything — the test cert is + // self-signed and not in any trust store, which is exactly the dev + // workflow this feature targets. + struct NoVerify; + + impl rustls::client::ServerCertVerifier for NoVerify { + fn verify_server_cert( + &self, + _end_entity: &rustls::Certificate, + _intermediates: &[rustls::Certificate], + _server_name: &rustls::ServerName, + _scts: &mut dyn Iterator, + _ocsp_response: &[u8], + _now: std::time::SystemTime, + ) -> Result { + Ok(rustls::client::ServerCertVerified::assertion()) + } + } + + fn tls_stream(addr: std::net::SocketAddr) -> StreamOwned { + let config = ClientConfig::builder() + .with_safe_defaults() + .with_custom_certificate_verifier(Arc::new(NoVerify)) + .with_no_client_auth(); + let server_name = rustls::ServerName::try_from("localhost").expect("server name"); + let conn = ClientConnection::new(Arc::new(config), server_name).expect("client conn"); + let sock = TcpStream::connect(addr).expect("connect"); + sock.set_read_timeout(Some(Duration::from_secs(5))) + .expect("read timeout"); + StreamOwned::new(conn, sock) + } + + #[test] + fn serves_index_over_https_with_injected_live_reload_script() { + let fx = TlsFixture::new(); + let mut stream = tls_stream(fx.server.addr()); + let req = "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + stream.write_all(req.as_bytes()).expect("write"); + stream.flush().expect("flush"); + + let mut raw = Vec::new(); + stream.read_to_end(&mut raw).expect("read response"); + let text = String::from_utf8_lossy(&raw); + + let status_line = text.lines().next().expect("status line"); + assert!(status_line.contains("200"), "status was {status_line}"); + // The SPA index is served and the live-reload client is injected, + // proving TLS framing of an ordinary file response works. + assert!(text.contains("

secure

"), "body missing app markup"); + assert!( + text.contains(LIVE_RELOAD_SCRIPT), + "live-reload script not injected over https" + ); + } + + #[test] + fn scheme_reports_https_when_tls_enabled() { + let fx = TlsFixture::new(); + assert_eq!(fx.server.scheme(), "https"); + } + + #[test] + fn sse_live_reload_stream_works_over_https() { + let fx = TlsFixture::new(); + let stream = tls_stream(fx.server.addr()); + let mut writer = stream; + let req = + "GET /__ngc_reload HTTP/1.1\r\nHost: localhost\r\nAccept: text/event-stream\r\n\r\n"; + writer.write_all(req.as_bytes()).expect("write"); + writer.flush().expect("flush"); + + let mut reader = BufReader::new(writer); + let mut status_line = String::new(); + reader.read_line(&mut status_line).expect("status line"); + assert!(status_line.contains("200"), "status was {status_line}"); + + let mut saw_event_stream = 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().contains("text/event-stream") { + saw_event_stream = true; + } + } + assert!( + saw_event_stream, + "missing event-stream content type over tls" + ); + + let mut connected = String::new(); + reader.read_line(&mut connected).expect("connected"); + assert!(connected.starts_with(": connected"), "got {connected:?}"); + let mut blank = String::new(); + reader.read_line(&mut blank).expect("blank"); + + std::thread::sleep(Duration::from_millis(100)); + fx.server.trigger_reload().expect("trigger reload"); + + let mut event = String::new(); + reader.read_line(&mut event).expect("event line"); + assert_eq!(event, "event: reload\n"); + let mut data = String::new(); + reader.read_line(&mut data).expect("data line"); + assert_eq!(data, "data: rebuild\n"); + } + + #[test] + fn serves_over_https_with_explicit_cert_and_key() { + // Mint a cert/key pair and feed the raw PEM through `from_pem` — the + // path explicit sslKey/sslCert files take — then confirm the server + // comes up and serves over TLS. + let ck = rcgen_pair(); + let root = TempDir::new().expect("tempdir"); + std::fs::write( + root.path().join("index.html"), + b"ok", + ) + .expect("write index"); + let cfg = DevServerConfig::new(root.path()) + .with_port(0) + .with_tls(Some(TlsConfig::from_pem(ck.0, ck.1))); + let (_tx, rx) = channel::(); + let server = DevServer::start(cfg, rx).expect("start with explicit pem"); + + let mut stream = tls_stream(server.addr()); + let req = "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + stream.write_all(req.as_bytes()).expect("write"); + stream.flush().expect("flush"); + let mut raw = Vec::new(); + stream.read_to_end(&mut raw).expect("read"); + let text = String::from_utf8_lossy(&raw); + assert!(text.lines().next().unwrap_or("").contains("200")); + } + + // Generate a (cert_pem, key_pem) pair the same way the production + // self-signed path does, but expose the raw PEM so the test can feed it + // through `TlsConfig::from_pem`. + fn rcgen_pair() -> (Vec, Vec) { + let ck = rcgen::generate_simple_self_signed(vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + ]) + .expect("rcgen"); + ( + ck.cert.pem().into_bytes(), + ck.signing_key.serialize_pem().into_bytes(), + ) + } +} diff --git a/packages/builder/schemas/dev-server.json b/packages/builder/schemas/dev-server.json index 324fe32..3396d2f 100644 --- a/packages/builder/schemas/dev-server.json +++ b/packages/builder/schemas/dev-server.json @@ -27,16 +27,16 @@ }, "ssl": { "type": "boolean", - "description": "Serve over HTTPS. Currently unsupported by ngc-rs serve. Setting this to true fails the build with an explanatory error.", + "description": "Serve over HTTPS. When set without sslKey/sslCert, the dev server generates a throwaway self-signed certificate for the bind host plus the loopback names; browsers show the usual untrusted-certificate warning. Cannot be combined with proxyConfig — terminate TLS at the proxy or drop the proxy when enabling ssl.", "default": false }, "sslKey": { "type": "string", - "description": "SSL key path. Currently unsupported." + "description": "Path to a PEM-encoded private key used when ssl is true. Resolved relative to the workspace root. Requires sslCert." }, "sslCert": { "type": "string", - "description": "SSL certificate path. Currently unsupported." + "description": "Path to a PEM-encoded certificate used when ssl is true. Resolved relative to the workspace root. Requires sslKey." }, "proxyConfig": { "type": "string", diff --git a/packages/builder/src/serve/__tests__/options.test.ts b/packages/builder/src/serve/__tests__/options.test.ts index 584c5ba..e228107 100644 --- a/packages/builder/src/serve/__tests__/options.test.ts +++ b/packages/builder/src/serve/__tests__/options.test.ts @@ -51,18 +51,55 @@ describe('translateOptions', () => { expect(t.args[portIdx + 1]).toBe('0'); }); - it('rejects ssl=true with a clear error', () => { + it('forwards --ssl and uses an https url when ssl is true without key/cert', () => { + const t = translateOptions({ ...base, ssl: true }, '/ws'); + expect(t.args).toContain('--ssl'); + expect(t.args).not.toContain('--ssl-key'); + expect(t.args).not.toContain('--ssl-cert'); + expect(t.url).toBe('https://localhost:4200/'); + }); + + it('forwards resolved --ssl-key/--ssl-cert when both are provided', () => { + const t = translateOptions( + { ...base, ssl: true, sslKey: 'certs/dev.key', sslCert: 'certs/dev.crt' }, + '/ws', + ); + expect(t.args).toContain('--ssl'); + const keyIdx = t.args.indexOf('--ssl-key'); + const certIdx = t.args.indexOf('--ssl-cert'); + expect(t.args[keyIdx + 1]).toBe('/ws/certs/dev.key'); + expect(t.args[certIdx + 1]).toBe('/ws/certs/dev.crt'); + expect(t.url).toBe('https://localhost:4200/'); + }); + + it('throws when only one of sslKey/sslCert is provided', () => { + expect(() => + translateOptions({ ...base, ssl: true, sslKey: '/k' }, '/ws'), + ).toThrow(OptionTranslationError); expect(() => - translateOptions({ ...base, ssl: true }, '/ws'), + translateOptions({ ...base, ssl: true, sslCert: '/c' }, '/ws'), ).toThrow(OptionTranslationError); }); - it('rejects sslKey/sslCert', () => { + it('rejects ssl combined with proxyConfig', () => { expect(() => - translateOptions({ ...base, sslKey: '/k' }, '/ws'), + translateOptions( + { ...base, ssl: true, proxyConfig: 'proxy.conf.json' }, + '/ws', + ), ).toThrow(OptionTranslationError); }); + it('ignores sslKey/sslCert and stays on http when ssl is not enabled', () => { + const t = translateOptions( + { ...base, sslKey: 'certs/dev.key', sslCert: 'certs/dev.crt' }, + '/ws', + ); + expect(t.args).not.toContain('--ssl'); + expect(t.args).not.toContain('--ssl-key'); + expect(t.url).toBe('http://localhost:4200/'); + }); + it('honors a custom project tsconfig', () => { const t = translateOptions( { ...base, project: 'tsconfig.app.json' }, @@ -203,4 +240,12 @@ describe('formatUrl', () => { 'http://localhost:4200/admin/', ); }); + it('uses the https scheme when requested', () => { + expect(formatUrl('localhost', 4200, null, 'https')).toBe( + 'https://localhost:4200/', + ); + expect(formatUrl('app.local', 8080, '/admin/', 'https')).toBe( + 'https://app.local:8080/admin/', + ); + }); }); diff --git a/packages/builder/src/serve/options.ts b/packages/builder/src/serve/options.ts index c3e4d62..c22e9b5 100644 --- a/packages/builder/src/serve/options.ts +++ b/packages/builder/src/serve/options.ts @@ -46,20 +46,10 @@ export function translateOptions( raw: Partial, workspaceRoot: string, ): TranslatedServeArgs { - if (raw.ssl === true) { - throw new OptionTranslationError( - 'ssl=true is not yet supported by ngc-rs serve. Remove the option or run a separate TLS-terminating proxy in front of ngc-rs.', - ); - } - if (raw.sslKey || raw.sslCert) { - throw new OptionTranslationError( - 'sslKey/sslCert are not yet supported by ngc-rs serve.', - ); - } - const userPort = raw.port ?? DEFAULT_PORT; const userHost = raw.host ?? DEFAULT_HOST; const open = raw.open === true; + const ssl = raw.ssl === true; const configuration = parseConfigurationFromBuildTarget(raw.buildTarget); const project = raw.project ?? 'tsconfig.json'; @@ -70,6 +60,22 @@ export function translateOptions( : null; const proxyEnabled = proxyConfigPath !== null; + // The proxy is the browser-facing endpoint, so HTTPS would have to be + // terminated there rather than at the spawned ngc-rs serve. That's not + // wired up, so reject the combination with an actionable message rather + // than silently serving plain HTTP behind the proxy. + if (ssl && proxyEnabled) { + throw new OptionTranslationError( + 'ssl cannot be combined with proxyConfig in ngc-rs serve. Remove proxyConfig to serve HTTPS directly, or terminate TLS at a proxy in front of the (plain-HTTP) dev server.', + ); + } + + // Resolve the SSL flags up-front so a bad key/cert combination fails the + // build before the server is spawned. `ssl` is the master switch: + // sslKey/sslCert are honored only when ssl is true (matching + // `@angular/build:dev-server`). + const sslArgs = buildSslArgs(raw, workspaceRoot, ssl); + const spawnHost = proxyEnabled ? '127.0.0.1' : userHost; const spawnPort = proxyEnabled ? 0 : userPort; @@ -89,6 +95,7 @@ export function translateOptions( if (headers !== null) { args.push('--headers', headers); } + args.push(...sslArgs); return { args, @@ -100,10 +107,41 @@ export function translateOptions( proxyPort: userPort, proxyConfigPath, open, - url: formatUrl(userHost, userPort, servePath), + url: formatUrl(userHost, userPort, servePath, ssl ? 'https' : 'http'), }; } +// Translate the `ssl`/`sslKey`/`sslCert` options into CLI flags for the +// spawned `ngc-rs serve`. Returns an empty array when ssl is off. When ssl +// is on: +// * both sslKey and sslCert set → forward `--ssl --ssl-key

--ssl-cert +//

` with the paths resolved against the workspace root; +// * exactly one set → throw, since both halves are required; +// * neither set → forward just `--ssl` and let the binary mint a +// self-signed certificate. +function buildSslArgs( + raw: Partial, + workspaceRoot: string, + ssl: boolean, +): string[] { + if (!ssl) { + return []; + } + const key = raw.sslKey ?? null; + const cert = raw.sslCert ?? null; + if ((key && !cert) || (!key && cert)) { + throw new OptionTranslationError( + 'ssl requires both sslKey and sslCert, or neither (to auto-generate a self-signed certificate).', + ); + } + const args = ['--ssl']; + if (key && cert) { + args.push('--ssl-key', path.resolve(workspaceRoot, key)); + args.push('--ssl-cert', path.resolve(workspaceRoot, cert)); + } + return args; +} + // Normalize a user-supplied servePath into the canonical `/foo/` form, or // return null when the value is empty / a bare `/` (i.e. no prefix). The // rust side runs the same normalization (`ngc_dev_server::normalize_serve_path`), @@ -196,9 +234,10 @@ export function formatUrl( host: string, port: number, servePath: string | null = null, + scheme: 'http' | 'https' = 'http', ): string { const isLoopbackName = host === 'localhost' || host === '0.0.0.0'; const display = isLoopbackName ? 'localhost' : host; const suffix = servePath ?? '/'; - return `http://${display}:${port}${suffix}`; + return `${scheme}://${display}:${port}${suffix}`; } From 44fd0691090860a83469b4d3a350b904ecaaf84b Mon Sep 17 00:00:00 2001 From: lukekania Date: Mon, 8 Jun 2026 17:00:51 +0200 Subject: [PATCH 11/20] feat(dev-server): hmr config + global CSS hot-swap (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of Hot Module Replacement: read the `hmr` flag and hot-swap global stylesheets in place without a full page reload. - project-resolver: parse `architect.serve.options.hmr` (base + active serve configuration override) into `ResolvedAngularProject.hmr`; defaults to false. - cli: add `--hmr` / `--no-hmr` to `serve` (CLI wins over angular.json, else inherit, else false). When HMR is on and a rebuild touches only global stylesheet entries, emit a CSS-update instead of a reload. - dev-server: new `DevServerEvent::CssUpdate { timestamp }` → `event: css-update` SSE frame; the injected client swaps the `styles.css` with a cache-busted href, preserving page state. Component template/style HMR and the `/@ng/component` update endpoint land in follow-up slices on the milestone branch. --- crates/cli/src/main.rs | 25 ++++- crates/cli/src/serve_cmd.rs | 103 +++++++++++++++++++- crates/dev-server/src/lib.rs | 47 ++++++++- crates/project-resolver/src/angular_json.rs | 103 ++++++++++++++++++++ 4 files changed, 275 insertions(+), 3 deletions(-) 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..d505762 100644 --- a/crates/cli/src/serve_cmd.rs +++ b/crates/cli/src/serve_cmd.rs @@ -40,6 +40,7 @@ pub fn run( ssl: bool, ssl_key: Option<&Path>, ssl_cert: Option<&Path>, + hmr_override: Option, ) -> NgcResult<()> { run_with_stop( project, @@ -53,6 +54,7 @@ pub fn run( ssl, ssl_key, ssl_cert, + hmr_override, install_ctrlc, ) } @@ -121,6 +123,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 +131,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`). @@ -185,6 +207,9 @@ 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(); + // 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 +234,19 @@ pub(crate) fn run_with_stop( result.modules_bundled, dirty.len() ); - if event_tx.send(DevServerEvent::Reload).is_err() { + // 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 +286,28 @@ 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()) +} + +/// 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 +508,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..b1e2058 100644 --- a/crates/dev-server/src/lib.rs +++ b/crates/dev-server/src/lib.rs @@ -46,10 +46,20 @@ 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 rebuild failed — connected browsers should display an error /// overlay with the message and (when available) the offending file /// and source coordinates. @@ -695,6 +705,9 @@ 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::BuildFailed { message, file, @@ -1074,7 +1087,7 @@ 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#""#; /// Insert the live-reload client script into an HTML byte buffer. /// @@ -1246,6 +1259,38 @@ 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_build_failed_emits_named_event_with_json_payload() { let event = DevServerEvent::BuildFailed { 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#"{ From f29bc30151db67478d221f4837c43d5f45a61d20 Mon Sep 17 00:00:00 2001 From: lukekania Date: Mon, 8 Jun 2026 17:09:20 +0200 Subject: [PATCH 12/20] feat(dev-server): /@ng/component endpoint + HMR runtime bus (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime scaffolding for component-level HMR, ahead of the compiler codegen that populates it. - dev-server: new `DevServerEvent::ComponentUpdate { id, timestamp }` → `event: angular:component-update` SSE frame; a shared `ComponentUpdates` registry (id → update-module source) on `DevServerConfig`, served at `GET /@ng/component?c=` (text/javascript, empty 200 for unknown ids, 400 when `c` is absent) — mirrors @angular/build's component middleware. - client: the injected script publishes a `window.__ngcHmr` bus and dispatches `angular:component-update` to registered handlers; new `HMR_RUNTIME_PRELUDE` binds `import.meta.hot` to that bus. - serve: share the registry with the dev server and prepend the runtime prelude to `main.js` on each HMR build so per-component initializers can resolve `import.meta.hot`. The registry stays empty and component edits still trigger a full reload until the template-compiler emits update modules (next slice). --- crates/cli/src/serve_cmd.rs | 53 ++++++++- crates/dev-server/src/lib.rs | 144 ++++++++++++++++++++++++- crates/dev-server/tests/integration.rs | 39 +++++++ 3 files changed, 233 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/serve_cmd.rs b/crates/cli/src/serve_cmd.rs index d505762..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}; @@ -174,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()) @@ -181,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() { @@ -207,6 +224,7 @@ 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; @@ -234,6 +252,10 @@ pub(crate) fn run_with_stop( result.modules_bundled, dirty.len() ); + // 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). @@ -294,6 +316,33 @@ 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 diff --git a/crates/dev-server/src/lib.rs b/crates/dev-server/src/lib.rs index b1e2058..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}; @@ -60,6 +61,18 @@ pub enum DevServerEvent { /// 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. @@ -88,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 { @@ -117,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 { @@ -131,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(); @@ -571,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 || { @@ -581,6 +616,7 @@ impl DevServer { serve_path_for_loop, allowed_hosts_for_loop, custom_headers, + component_updates, ) }) .map_err(|e| NgcError::ServeError { @@ -708,6 +744,12 @@ pub fn sse_frame(event: &DevServerEvent) -> 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, @@ -725,6 +767,7 @@ pub fn sse_frame(event: &DevServerEvent) -> String { } } +#[allow(clippy::too_many_arguments)] fn serve_loop( server: Arc, root: PathBuf, @@ -732,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(); @@ -739,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, @@ -747,6 +792,7 @@ fn serve_loop( serve_path.as_deref(), &allowed_hosts, &headers, + &component_updates, ) { tracing::warn!(error = %e, "dev server request failed"); } @@ -754,6 +800,7 @@ fn serve_loop( } } +#[allow(clippy::too_many_arguments)] fn handle_request( request: tiny_http::Request, root: &Path, @@ -761,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)); @@ -787,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 @@ -1087,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. /// @@ -1291,6 +1397,42 @@ mod tests { ); } + #[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(); From 95242a119411d19cd2199b227ace59625d2e6696 Mon Sep 17 00:00:00 2001 From: lukekania Date: Mon, 8 Jun 2026 19:49:29 +0200 Subject: [PATCH 13/20] feat(hmr): component template & style hot module replacement (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lights up Angular-parity template/style HMR end to end, building on the config plumbing, dev-server endpoint, and runtime bus from the previous slices. template-compiler: - New `hmr` module: `encode_uri_component` (JS semantics), per-component id `encodeURIComponent("relpath@Class")`, the initializer IIFE, and the update-module codegen. The update module reuses the existing Ivy def verbatim via the linker's var-alias technique (`var XX = i0.XX`), with `@angular/core` arriving as the `namespaces` arg and template deps as positional params; it reassigns only `ɵcmp` (template/style-only path). - `CompileOptions.hmr` and `CompiledFile.hmr` carry the artifacts; `rewrite_source` adds `import * as i0` so the appended initializer can reach `ɵɵreplaceMetadata`. cli: - Thread `hmr` through `run_build_with_options`; aggregate per-component update modules and resource→component maps into `BuildResult`; cache the artifacts in the incremental `CachedModule`. - serve: classify each rebuild — global stylesheet → css-update, external component template/style → component-update(s), `.ts`/inline/unknown → full reload — and inject the `import.meta.hot` prelude into every chunk (eager `main.js` and lazy `chunk-*.js`). Verified against an Angular 21 app: editing a component `.html`/`.scss` swaps it in place via the `/@ng/component` update module and `ɵɵreplaceMetadata`; global styles swap the ``; `.ts` reloads. --- crates/cli/src/incremental.rs | 6 + crates/cli/src/main.rs | 48 ++++- crates/cli/src/serve_cmd.rs | 218 +++++++++++++++++++---- crates/template-compiler/Cargo.toml | 2 +- crates/template-compiler/src/hmr.rs | 223 ++++++++++++++++++++++++ crates/template-compiler/src/lib.rs | 155 +++++++++++++++- crates/template-compiler/src/rewrite.rs | 18 +- 7 files changed, 625 insertions(+), 45 deletions(-) create mode 100644 crates/template-compiler/src/hmr.rs diff --git a/crates/cli/src/incremental.rs b/crates/cli/src/incremental.rs index e365a1e..b3a4c3c 100644 --- a/crates/cli/src/incremental.rs +++ b/crates/cli/src/incremental.rs @@ -40,6 +40,11 @@ pub struct CachedModule { pub transformed_code: String, /// Optional source map for the transformed JS. pub transformed_map: Option, + /// HMR artifacts captured during the original compile (when `--hmr` is + /// active). Reused on a cache hit so an unchanged component still + /// contributes its update module to the dev server's registry. `None` + /// for non-HMR builds. + pub hmr: Option, } /// Per-build-pipeline module cache. @@ -135,6 +140,7 @@ mod tests { jit_fallback: false, transformed_code: "// transformed".to_string(), transformed_map: None, + hmr: None, } } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 11e5d9c..6c0771f 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -141,6 +141,16 @@ struct BuildResult { total_size_bytes: u64, /// Wall-clock duration of the build pipeline. duration_ms: u64, + /// HMR component-update modules, keyed by component id. Populated only + /// when the build runs with HMR enabled (`serve --hmr`); internal to the + /// dev server, so excluded from the `--output-json` shape. + #[serde(skip)] + hmr_component_updates: HashMap, + /// Map from an external resource (`templateUrl`/`styleUrls`) path to the + /// owning component's HMR id, so a changed `.html`/`.css` maps to the + /// component to hot-swap. + #[serde(skip)] + hmr_resource_to_component: HashMap, } #[derive(Parser)] @@ -554,6 +564,8 @@ fn main() { modules_bundled: 0, total_size_bytes: 0, duration_ms: started.elapsed().as_millis() as u64, + hmr_component_updates: HashMap::new(), + hmr_resource_to_component: HashMap::new(), }; let json = serde_json::to_string_pretty(&result) .expect("BuildResult serialization should not fail"); @@ -605,6 +617,7 @@ fn run_build( strict_templates, None, None, + false, ) } @@ -631,6 +644,7 @@ pub(crate) fn run_build_with_cache( false, cache, None, + false, ) } @@ -644,6 +658,7 @@ pub(crate) fn run_build_with_cache( /// `strict_templates` mirrors the `--strict-templates` build flag: when /// true, any template that would otherwise fall back to JIT compilation /// produces an [`NgcError::TemplateCompileError`] instead. +#[allow(clippy::too_many_arguments)] pub(crate) fn run_build_with_options( project: &Path, out_dir_override: Option<&Path>, @@ -652,6 +667,7 @@ pub(crate) fn run_build_with_options( strict_templates: bool, mut cache: Option<&mut incremental::BuildCache>, base_href_override: Option<&str>, + hmr: bool, ) -> NgcResult { let started_at = Instant::now(); @@ -720,7 +736,10 @@ pub(crate) fn run_build_with_options( .iter() .filter_map(|p| incremental::BuildCache::hash_file(p).map(|h| (p.clone(), h))) .collect(); - let compile_opts = ngc_template_compiler::CompileOptions { strict_templates }; + let compile_opts = ngc_template_compiler::CompileOptions { + strict_templates, + hmr, + }; let (compiled, transform_cache_seed) = compile_decorators_cached( &files, &style_ctx, @@ -730,6 +749,29 @@ pub(crate) fn run_build_with_options( )?; drop(templates_span); + // Aggregate HMR artifacts (when enabled) into lookup maps the dev server + // consumes: id → update module, and source/resource path → component id. + let mut hmr_component_updates: HashMap = HashMap::new(); + let mut hmr_resource_to_component: HashMap = HashMap::new(); + if hmr { + for cf in &compiled { + if let Some(artifacts) = &cf.hmr { + for comp in &artifacts.components { + hmr_component_updates + .insert(comp.id.clone(), comp.update_module_source.clone()); + for resource in &comp.resource_files { + // Canonicalize so the watcher's emitted paths (also + // canonicalized at lookup) match across symlinks. + let key = resource + .canonicalize() + .unwrap_or_else(|_| resource.clone()); + hmr_resource_to_component.insert(key, comp.id.clone()); + } + } + } + } + } + // Report any JIT fallbacks. Each fallback also goes into // `BuildResult.warnings` so the architect builder shim can surface it // through `BuilderContext.logger.warn` rather than relying on stderr @@ -1485,6 +1527,8 @@ pub(crate) fn run_build_with_options( modules_bundled, total_size_bytes, duration_ms: started_at.elapsed().as_millis() as u64, + hmr_component_updates, + hmr_resource_to_component, }) } @@ -2841,6 +2885,7 @@ fn compile_decorators_cached( source: hit.compiled_source, compiled: true, jit_fallback: hit.jit_fallback, + hmr: hit.hmr, }); continue; } @@ -2868,6 +2913,7 @@ fn compile_decorators_cached( // Filled in by the transform step. transformed_code: String::new(), transformed_map: None, + hmr: cf.hmr.clone(), }, ); } diff --git a/crates/cli/src/serve_cmd.rs b/crates/cli/src/serve_cmd.rs index a990ba4..ee4dc88 100644 --- a/crates/cli/src/serve_cmd.rs +++ b/crates/cli/src/serve_cmd.rs @@ -171,6 +171,7 @@ pub(crate) fn run_with_stop( false, Some(&mut cache), normalized_serve_path.as_deref(), + hmr_enabled, )?; eprintln!( "{} {} module(s), {} file(s)", @@ -180,9 +181,14 @@ pub(crate) fn run_with_stop( ); // 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. + // `/@ng/component`. Seed it from the initial build; the rebuild callback + // replaces its contents each cycle. let component_updates: ComponentUpdates = Arc::new(Mutex::new(HashMap::new())); + if hmr_enabled { + if let Ok(mut map) = component_updates.lock() { + map.clone_from(&initial.hmr_component_updates); + } + } // When HMR is on, bind `import.meta.hot` inside the entry module so the // per-component initializers can register update handlers. @@ -225,8 +231,9 @@ pub(crate) fn run_with_stop( 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 registry = Arc::clone(&component_updates); + // Monotonic cache-buster for swapped stylesheets and component-update + // fetches; must change every rebuild so the browser re-fetches. let mut hmr_tick: u64 = 0; let build_fn = move |dirty: &[PathBuf]| -> NgcResult<()> { @@ -243,6 +250,7 @@ pub(crate) fn run_with_stop( false, Some(&mut cache), serve_path_owned.as_deref(), + hmr_enabled, ); match outcome { Ok(result) => { @@ -252,24 +260,26 @@ pub(crate) fn run_with_stop( result.modules_bundled, dirty.len() ); - // Re-bind `import.meta.hot` in the freshly written entry chunk. if hmr_enabled { + // Re-bind `import.meta.hot` in the freshly written entry + // chunk and refresh the served update-module registry. inject_hmr_runtime(&out_dir_owned); + if let Ok(mut map) = registry.lock() { + map.clone_from(&result.hmr_component_updates); + } } - // 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, + hmr_tick += 1; + for event in classify_rebuild( + hmr_enabled, + dirty, + &global_style_paths, + &result.hmr_resource_to_component, + hmr_tick, + ) { + if event_tx.send(event).is_err() { + tracing::debug!("dev server event channel closed"); + break; } - } else { - DevServerEvent::Reload - }; - if event_tx.send(event).is_err() { - tracing::debug!("dev server event channel closed"); } Ok(()) } @@ -316,30 +326,40 @@ 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. +/// Prepend the HMR runtime prelude to every emitted JS chunk so the +/// per-component HMR initializers — which live in `main.js` for eager +/// components and in lazy `chunk-*.js` for routed ones — can resolve +/// `import.meta.hot` (each ES module has its own `import.meta`). The build +/// rewrites the chunks from scratch each cycle, so this runs after every +/// successful build. A per-file guard skips the work if the prelude is already +/// present. Failures are logged and ignored: a chunk that can't be patched +/// just degrades that component to live reload rather than breaking the 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, + let entries = match std::fs::read_dir(out_dir) { + Ok(e) => e, Err(e) => { - tracing::debug!(path = %main_js.display(), error = %e, "no entry chunk to inject HMR runtime into"); + tracing::debug!(path = %out_dir.display(), error = %e, "could not read out_dir to inject HMR runtime"); 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"); + for entry in entries.flatten() { + let path = entry.path(); + // Top-level `.js` chunks only (skip source maps and nested locale dirs). + if path.extension().and_then(|e| e.to_str()) != Some("js") { + continue; + } + let Ok(existing) = std::fs::read_to_string(&path) else { + continue; + }; + if existing.starts_with(HMR_RUNTIME_PRELUDE) { + continue; + } + 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(&path, patched) { + tracing::debug!(path = %path.display(), error = %e, "could not inject HMR runtime into chunk"); + } } } @@ -357,6 +377,49 @@ fn is_global_css_only_change( .all(|p| global_style_paths.contains(&canonical_or_owned(p))) } +/// Decide which dev-server event(s) a successful rebuild should fan out. +/// +/// * HMR off → always a full [`DevServerEvent::Reload`]. +/// * Every dirty file is a global stylesheet → one [`DevServerEvent::CssUpdate`]. +/// * Every dirty file is an external component resource (`templateUrl` / +/// `styleUrls`) of a known component → one [`DevServerEvent::ComponentUpdate`] +/// per affected component, swapping it in place. +/// * Anything else (a `.ts` class change, an inline-template edit, or an +/// unrecognised file) → a full reload. Inline-template/`.ts` HMR is +/// intentionally deferred to a follow-up. +fn classify_rebuild( + hmr_enabled: bool, + dirty: &[PathBuf], + global_style_paths: &std::collections::HashSet, + resource_to_component: &HashMap, + timestamp: u64, +) -> Vec { + if !hmr_enabled { + return vec![DevServerEvent::Reload]; + } + if is_global_css_only_change(dirty, global_style_paths) { + return vec![DevServerEvent::CssUpdate { timestamp }]; + } + if dirty.is_empty() { + return vec![DevServerEvent::Reload]; + } + let mut ids: Vec = Vec::new(); + for path in dirty { + match resource_to_component.get(&canonical_or_owned(path)) { + Some(id) => { + if !ids.contains(id) { + ids.push(id.clone()); + } + } + // A `.ts` change, inline-template edit, or unknown file → reload. + None => return vec![DevServerEvent::Reload], + } + } + ids.into_iter() + .map(|id| DevServerEvent::ComponentUpdate { id, timestamp }) + .collect() +} + fn error_location(err: &NgcError) -> (Option, Option, Option) { match err { NgcError::ParseError { @@ -599,6 +662,87 @@ mod tests { assert!(!is_global_css_only_change(&[], &styles)); } + #[test] + fn classify_rebuild_routes_events() { + use std::collections::{HashMap, HashSet}; + let styles: HashSet = [PathBuf::from("/proj/src/styles.css")].into_iter().collect(); + let mut resources: HashMap = HashMap::new(); + resources.insert(PathBuf::from("/proj/src/app/app.component.html"), "id-app".to_string()); + resources.insert(PathBuf::from("/proj/src/app/app.component.css"), "id-app".to_string()); + resources.insert(PathBuf::from("/proj/src/app/foo.component.html"), "id-foo".to_string()); + + // HMR off → always reload. + assert!(matches!( + classify_rebuild(false, &[PathBuf::from("/proj/src/app/app.component.html")], &styles, &resources, 1).as_slice(), + [DevServerEvent::Reload] + )); + + // Global stylesheet only → CssUpdate. + assert!(matches!( + classify_rebuild(true, &[PathBuf::from("/proj/src/styles.css")], &styles, &resources, 5).as_slice(), + [DevServerEvent::CssUpdate { timestamp: 5 }] + )); + + // A component template → one ComponentUpdate for its id. + let evs = classify_rebuild(true, &[PathBuf::from("/proj/src/app/app.component.html")], &styles, &resources, 7); + match evs.as_slice() { + [DevServerEvent::ComponentUpdate { id, timestamp: 7 }] => assert_eq!(id, "id-app"), + other => panic!("expected one ComponentUpdate, got {other:?}"), + } + + // Two resources of the same component → deduped to a single update. + let evs = classify_rebuild( + true, + &[ + PathBuf::from("/proj/src/app/app.component.html"), + PathBuf::from("/proj/src/app/app.component.css"), + ], + &styles, + &resources, + 9, + ); + assert_eq!(evs.len(), 1); + + // Two distinct components → one update each. + let evs = classify_rebuild( + true, + &[ + PathBuf::from("/proj/src/app/app.component.html"), + PathBuf::from("/proj/src/app/foo.component.html"), + ], + &styles, + &resources, + 9, + ); + assert_eq!(evs.len(), 2); + + // A `.ts` (or any unknown) change → reload, even mixed with a resource. + assert!(matches!( + classify_rebuild(true, &[PathBuf::from("/proj/src/app/app.component.ts")], &styles, &resources, 1).as_slice(), + [DevServerEvent::Reload] + )); + assert!(matches!( + classify_rebuild( + true, + &[ + PathBuf::from("/proj/src/app/app.component.html"), + PathBuf::from("/proj/src/app/app.component.ts"), + ], + &styles, + &resources, + 1 + ) + .as_slice(), + [DevServerEvent::Reload] + )); + + // Empty dirty set → reload. + assert!(matches!( + classify_rebuild(true, &[], &styles, &resources, 1).as_slice(), + [DevServerEvent::Reload] + )); + } + #[test] fn build_failure_event_omits_path_for_pathless_errors() { let err = NgcError::ServeError { diff --git a/crates/template-compiler/Cargo.toml b/crates/template-compiler/Cargo.toml index a693736..a9c88d8 100644 --- a/crates/template-compiler/Cargo.toml +++ b/crates/template-compiler/Cargo.toml @@ -10,6 +10,7 @@ publish = false [dependencies] ngc-diagnostics = { path = "../diagnostics" } +ngc-ts-transform = { path = "../ts-transform" } serde_json = "1.0" oxc_allocator = "0.122" oxc_diagnostics = "0.122" @@ -23,7 +24,6 @@ tracing = "0.1" [dev-dependencies] ngc-project-resolver = { path = "../project-resolver" } -ngc-ts-transform = { path = "../ts-transform" } insta = { version = "1.46", features = ["glob"] } tempfile = "3" which = "8" diff --git a/crates/template-compiler/src/hmr.rs b/crates/template-compiler/src/hmr.rs new file mode 100644 index 0000000..64626f6 --- /dev/null +++ b/crates/template-compiler/src/hmr.rs @@ -0,0 +1,223 @@ +//! Hot Module Replacement (HMR) codegen for `@Component` classes. +//! +//! Mirrors Angular's esbuild HMR exactly (reverse-engineered from +//! `@angular/compiler` + `@angular/core`): +//! +//! * A per-component **initializer IIFE** is appended to the component module. +//! It registers an `import.meta.hot` listener for `angular:component-update` +//! and, on a matching id, dynamically imports the component's update module +//! and calls `i0.ɵɵreplaceMetadata` to swap the definition in place. +//! * A separate **update module** is produced (not written to disk — served on +//! demand by the dev server at `/@ng/component?c=`). Its `export default` +//! is a function that re-applies the freshly compiled `ɵcmp` to the existing +//! class. It carries no imports: the `@angular/core` namespace arrives as the +//! `ɵɵnamespaces` array argument and local template dependencies arrive as +//! positional parameters. +//! +//! The update module reassigns only `ɵcmp` (template + styles), never `ɵfac`: +//! the serve command only emits a component update when a component's factory +//! and class body are byte-identical to the previous build (template-/style- +//! only change), so the running factory is already correct. This keeps the +//! update module free of constructor-DI symbols it would otherwise need in +//! scope. + +use crate::codegen::IvyOutput; + +/// Percent-encode `input` with JavaScript `encodeURIComponent` semantics. +/// +/// `encodeURIComponent` leaves the "unreserved" set unescaped — +/// `A-Z a-z 0-9 - _ . ! ~ * ' ( )` — and `%XX`-encodes every other byte +/// (UTF-8, uppercase hex). This is deliberately *not* a generic URL encoder: +/// the result is the component id contract shared by the compiler-emitted +/// initializer, the dev-server registry key, and the running app's fetch URL. +pub fn encode_uri_component(input: &str) -> String { + fn is_unreserved(b: u8) -> bool { + b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'!' | b'~' | b'*' | b'\'' | b'(' | b')') + } + let mut out = String::with_capacity(input.len()); + for &b in input.as_bytes() { + if is_unreserved(b) { + out.push(b as char); + } else { + out.push('%'); + out.push(char::from_digit((b >> 4) as u32, 16).unwrap().to_ascii_uppercase()); + out.push(char::from_digit((b & 0xf) as u32, 16).unwrap().to_ascii_uppercase()); + } + } + out +} + +/// Compute a component's HMR id: `encodeURIComponent("@")`. +/// +/// `relpath` is `file_path` relative to `project_root` with `/` separators +/// (falling back to the file's own components when it isn't under the root). +/// The compiler is the sole producer of this id — it is embedded verbatim in +/// the initializer and used as the dev-server registry key — so the exact +/// `project_root` only needs to be applied *consistently*, not to match any +/// external value. +pub fn component_hmr_id( + project_root: &std::path::Path, + file_path: &std::path::Path, + class_name: &str, +) -> String { + let rel = file_path.strip_prefix(project_root).unwrap_or(file_path); + // Join components with `/` so the id is stable across platforms. + let rel_str = rel + .components() + .filter_map(|c| c.as_os_str().to_str()) + .collect::>() + .join("/"); + encode_uri_component(&format!("{rel_str}@{class_name}")) +} + +/// Build the per-component HMR initializer IIFE appended to the component +/// module. `locals` are the template dependency identifiers (the `imports:` +/// entries), passed to `ɵɵreplaceMetadata` in the same order the update +/// module declares its parameters. +/// +/// References `i0` (the `import * as i0 from '@angular/core'` namespace the +/// HMR rewrite adds) for `ɵɵreplaceMetadata`, and the component class plus +/// each local by binding — all resolved in the module's top-level scope. +pub fn build_initializer(class_name: &str, id: &str, locals: &[String]) -> String { + let locals_arr = locals.join(", "); + // Plain JS (no TS annotations) so it survives ts-transform untouched. + format!( + "(() => {{\n\ + var __ngId = '{id}';\n\ + function {class_name}_HmrLoad(t) {{\n\ + return import('./@ng/component?c=' + __ngId + '&t=' + encodeURIComponent(t)).then(\n\ + m => m.default && i0.\u{0275}\u{0275}replaceMetadata({class_name}, m.default, [i0], [{locals_arr}], import.meta, __ngId));\n\ + }}\n\ + if (import.meta.hot) {{\n\ + import.meta.hot.on('angular:component-update', d => {{ if (d.id === __ngId) {class_name}_HmrLoad(d.timestamp); }});\n\ + }}\n\ + }})();\n" + ) +} + +/// Build the TypeScript source of a component's HMR update module. +/// +/// The caller runs the result through `ngc_ts_transform::transform_source` to +/// strip TypeScript annotations (and validate it as JS) before serving it. +/// +/// Reuses the existing Ivy codegen verbatim via the linker's proven var-alias +/// technique: every runtime symbol (`ɵɵdefineComponent`, `ɵɵelement`, …) is +/// rebound from the `i0` namespace as a local `var`, so the unmodified `ɵcmp` +/// definition string — which references those symbols by their bare names — +/// resolves against the locals. Template dependency identifiers resolve to the +/// function's positional parameters. +pub fn build_update_module_ts(class_name: &str, ivy: &IvyOutput, locals: &[String]) -> String { + let mut out = String::new(); + + // export default function X_UpdateMetadata(X, ɵɵnamespaces, dep1, dep2) { + out.push_str(&format!( + "export default function {class_name}_UpdateMetadata({class_name}, \u{0275}\u{0275}namespaces" + )); + for local in locals { + out.push_str(", "); + out.push_str(local); + } + out.push_str(") {\n"); + + // Rebind every runtime symbol from the core namespace so the reused def + // text (which uses bare `ɵɵ…` names) resolves without imports. + out.push_str(" const i0 = \u{0275}\u{0275}namespaces[0];\n"); + for sym in &ivy.ivy_imports { + out.push_str(&format!(" var {sym} = i0.{sym};\n")); + } + + // Child template functions referenced by the main template, in scope. + for child in &ivy.child_template_functions { + out.push_str(child); + out.push('\n'); + } + + // Reassign the component definition. `static_fields[0]` is + // `static ɵcmp = ɵɵdefineComponent({...})`; turn the class-field form into + // an assignment statement on the class passed in as the first parameter. + let def = ivy.static_fields.first().map(|s| s.as_str()).unwrap_or(""); + let def_expr = def + .strip_prefix("static \u{0275}cmp = ") + .unwrap_or(def); + out.push_str(&format!(" {class_name}.\u{0275}cmp = {def_expr};\n")); + + out.push_str("}\n"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + use std::path::Path; + + #[test] + fn encode_uri_component_matches_js_semantics() { + assert_eq!( + encode_uri_component("src/app/app.component.ts@AppComponent"), + "src%2Fapp%2Fapp.component.ts%40AppComponent" + ); + // Unreserved set is left untouched. + assert_eq!(encode_uri_component("-_.!~*'()"), "-_.!~*'()"); + // Spaces, slashes, and unicode are percent-encoded (UTF-8, upper hex). + assert_eq!(encode_uri_component("a b/c"), "a%20b%2Fc"); + assert_eq!(encode_uri_component("é"), "%C3%A9"); + } + + #[test] + fn component_hmr_id_is_relative_with_forward_slashes() { + let id = component_hmr_id( + Path::new("/proj"), + Path::new("/proj/src/app/app.component.ts"), + "AppComponent", + ); + assert_eq!(id, "src%2Fapp%2Fapp.component.ts%40AppComponent"); + } + + #[test] + fn component_hmr_id_falls_back_when_not_under_root() { + let id = component_hmr_id( + Path::new("/other"), + Path::new("/proj/app.component.ts"), + "App", + ); + // Not under root → encodes the full path it was given. + assert!(id.ends_with("app.component.ts%40App")); + assert!(id.contains("%2F")); + } + + #[test] + fn initializer_embeds_id_locals_and_replace_metadata() { + let init = build_initializer("AppComponent", "the%2Fid%40AppComponent", &["RouterOutlet".into(), "MyPipe".into()]); + assert!(init.contains("var __ngId = 'the%2Fid%40AppComponent';")); + assert!(init.contains("i0.\u{0275}\u{0275}replaceMetadata(AppComponent, m.default, [i0], [RouterOutlet, MyPipe], import.meta, __ngId)")); + assert!(init.contains("import('./@ng/component?c=' + __ngId + '&t=' + encodeURIComponent(t))")); + assert!(init.contains("import.meta.hot.on('angular:component-update'")); + } + + #[test] + fn update_module_rebinds_namespace_and_assigns_cmp() { + let ivy = IvyOutput { + factory_code: "static \u{0275}fac = function App_Factory(t) { return new (t || App)(); }".into(), + static_fields: vec![ + "static \u{0275}cmp = \u{0275}\u{0275}defineComponent({ type: App, template: function App_Template(rf, ctx) {} })".into(), + ], + child_template_functions: vec!["function App_div_0_Template(rf, ctx) {}".into()], + ivy_imports: { + let mut s = BTreeSet::new(); + s.insert("\u{0275}\u{0275}defineComponent".to_string()); + s.insert("\u{0275}\u{0275}element".to_string()); + s + }, + consts: vec![], + }; + let module = build_update_module_ts("App", &ivy, &["RouterOutlet".into()]); + assert!(module.contains("export default function App_UpdateMetadata(App, \u{0275}\u{0275}namespaces, RouterOutlet)")); + assert!(module.contains("const i0 = \u{0275}\u{0275}namespaces[0];")); + assert!(module.contains("var \u{0275}\u{0275}defineComponent = i0.\u{0275}\u{0275}defineComponent;")); + assert!(module.contains("function App_div_0_Template")); + assert!(module.contains("App.\u{0275}cmp = \u{0275}\u{0275}defineComponent({")); + // The update module must not reassign the factory (template/style-only). + assert!(!module.contains(".\u{0275}fac")); + } +} diff --git a/crates/template-compiler/src/lib.rs b/crates/template-compiler/src/lib.rs index f0a7fbb..7c204df 100644 --- a/crates/template-compiler/src/lib.rs +++ b/crates/template-compiler/src/lib.rs @@ -10,6 +10,7 @@ mod codegen; mod directive_codegen; mod extract; mod factory_codegen; +pub mod hmr; pub mod host_codegen; pub mod i18n; mod injectable_codegen; @@ -73,6 +74,11 @@ pub struct CompileOptions { /// `@angular/build:application`'s `strictTemplates` behaviour, which has /// no JIT fallback. pub strict_templates: bool, + /// When `true`, emit Angular HMR codegen for each compiled `@Component`: + /// a per-component initializer IIFE appended to the module, plus a + /// separate update module returned in [`CompiledFile::hmr`]. Dev-only + /// (`ngc-rs serve --hmr`); production builds leave this `false`. + pub hmr: bool, } /// Lightweight metadata for template compilation without `ExtractedComponent`. @@ -255,6 +261,36 @@ pub struct CompiledFile { pub compiled: bool, /// Whether JIT fallback was used (decorator left as-is). pub jit_fallback: bool, + /// HMR artifacts for this file, present only when [`CompileOptions::hmr`] + /// is set and a component was compiled. `None` otherwise. + pub hmr: Option, +} + +/// HMR artifacts produced for one source file when HMR codegen is enabled. +#[derive(Debug, Clone)] +pub struct HmrArtifacts { + /// One entry per `@Component` in the file. Today the compiler emits at + /// most one (the pipeline is single-component per file); a `Vec` keeps + /// the shape forward-compatible. + pub components: Vec, +} + +/// HMR codegen for a single component: its id, the update module the dev +/// server serves at `/@ng/component?c=`, and the resource files whose +/// edits map back to this component. +#[derive(Debug, Clone)] +pub struct HmrComponent { + /// The component class name. + pub class_name: String, + /// `encodeURIComponent("@")` — the dev-server + /// registry key and the id embedded in the component's initializer. + pub id: String, + /// The update module source (already transformed to JS): an ES module + /// whose `export default` re-applies the component's `ɵcmp`. + pub update_module_source: String, + /// Absolute paths of external resources (`templateUrl` + `styleUrls`). + /// A change to one of these maps the rebuild to this component id. + pub resource_files: Vec, } /// Compile all Angular decorators in the given TypeScript source files. @@ -408,6 +444,7 @@ fn compile_file_fallthrough( source: source.to_string(), compiled: false, jit_fallback: false, + hmr: None, }) } @@ -435,6 +472,7 @@ fn compile_file_dispatch( source: source.to_string(), compiled: false, jit_fallback: false, + hmr: None, }); } @@ -470,6 +508,7 @@ fn compile_file_dispatch( source: source.to_string(), compiled: false, jit_fallback: false, + hmr: None, }) } @@ -486,6 +525,7 @@ fn finalize_injectable( source: rewritten, compiled: true, jit_fallback: false, + hmr: None, }) } @@ -502,6 +542,7 @@ fn finalize_directive( source: rewritten, compiled: true, jit_fallback: false, + hmr: None, }) } @@ -518,6 +559,7 @@ fn finalize_pipe( source: rewritten, compiled: true, jit_fallback: false, + hmr: None, }) } @@ -534,6 +576,7 @@ fn finalize_ng_module( source: rewritten, compiled: true, jit_fallback: false, + hmr: None, }) } @@ -686,6 +729,7 @@ pub fn compile_component_with_options( source: source.to_string(), compiled: false, jit_fallback: false, + hmr: None, }); } }; @@ -711,6 +755,7 @@ pub fn compile_component_with_options( source: source.to_string(), compiled: false, jit_fallback: true, + hmr: None, }); } @@ -737,6 +782,7 @@ pub fn compile_component_with_options( source: source.to_string(), compiled: false, jit_fallback: false, + hmr: None, }); }; @@ -746,8 +792,42 @@ pub fn compile_component_with_options( // Generate Ivy code let ivy_output = codegen::generate_ivy(&extracted, &template_ast)?; - // Rewrite the source - let rewritten = rewrite::rewrite_source(source, &extracted, &ivy_output)?; + // Rewrite the source. In HMR mode the rewrite also adds an + // `import * as i0 from '@angular/core'` so the appended initializer can + // reach `i0.ɵɵreplaceMetadata` and pass the core namespace to the update. + let mut rewritten = rewrite::rewrite_source(source, &extracted, &ivy_output, compile_opts.hmr)?; + + // HMR codegen: build the per-component update module and append the + // initializer IIFE to the module. The update module is returned in + // `CompiledFile::hmr` for the dev server to serve on demand. + let hmr = if compile_opts.hmr { + let id = hmr::component_hmr_id(&style_ctx.project_root, file_path, &extracted.class_name); + let locals = &extracted.imports_identifiers; + let update_ts = hmr::build_update_module_ts(&extracted.class_name, &ivy_output, locals); + let update_module_source = ngc_ts_transform::transform_source(&update_ts, "ngc-hmr-update.ts")?; + rewritten.push('\n'); + rewritten.push_str(&hmr::build_initializer(&extracted.class_name, &id, locals)); + + let base_dir = file_path.parent().unwrap_or(Path::new(".")); + let mut resource_files = Vec::new(); + if let Some(ref url) = extracted.template_url { + resource_files.push(base_dir.join(url)); + } + for url in &extracted.style_urls { + resource_files.push(base_dir.join(url)); + } + + Some(HmrArtifacts { + components: vec![HmrComponent { + class_name: extracted.class_name.clone(), + id, + update_module_source, + resource_files, + }], + }) + } else { + None + }; debug!(path = %file_path.display(), "compiled template to Ivy"); @@ -756,6 +836,7 @@ pub fn compile_component_with_options( source: rewritten, compiled: true, jit_fallback: false, + hmr, }) } @@ -798,6 +879,7 @@ export class XComponent {} let style_ctx = StyleContext::default(); let strict = CompileOptions { strict_templates: true, + ..Default::default() }; let lenient = CompileOptions::default(); @@ -831,6 +913,75 @@ export class XComponent {} ); } + #[test] + fn test_hmr_codegen_produces_valid_artifacts() { + let source = "import { Component } from '@angular/core';\n\n@Component({\n selector: 'app-counter',\n standalone: true,\n template: '',\n styles: ['button { color: red; }'],\n})\nexport class CounterComponent {\n count = 0;\n inc() { this.count++; }\n}\n"; + let path = PathBuf::from("/proj/src/app/counter.component.ts"); + let style_ctx = StyleContext { + project_root: PathBuf::from("/proj"), + ..Default::default() + }; + let opts = CompileOptions { + hmr: true, + ..Default::default() + }; + let result = compile_component_with_options(source, &path, &style_ctx, &opts) + .expect("hmr compile should succeed"); + assert!(result.compiled); + + // The rewritten module must add the `i0` namespace import and the + // appended HMR initializer wired to `import.meta.hot`. + assert!(result.source.contains("import * as i0 from '@angular/core';")); + assert!(result + .source + .contains("import.meta.hot.on('angular:component-update'")); + assert!(result + .source + .contains("i0.\u{0275}\u{0275}replaceMetadata(CounterComponent")); + + // HMR artifacts present, with the expected id, and the update module + // is valid JavaScript that re-applies ɵcmp. + let hmr = result.hmr.expect("hmr artifacts present"); + assert_eq!(hmr.components.len(), 1); + let comp = &hmr.components[0]; + assert_eq!(comp.class_name, "CounterComponent"); + assert_eq!( + comp.id, + "src%2Fapp%2Fcounter.component.ts%40CounterComponent" + ); + assert!(comp + .update_module_source + .contains("CounterComponent.\u{0275}cmp")); + // The served update module must already be valid JS (TS stripped). + let alloc = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new( + &alloc, + &comp.update_module_source, + oxc_span::SourceType::mjs(), + ) + .parse(); + assert!( + parsed.errors.is_empty(), + "update module should be valid JS: {:?}\n\n{}", + parsed.errors, + comp.update_module_source + ); + // No factory reassignment (template/style-only update path). + assert!(!comp.update_module_source.contains(".\u{0275}fac")); + } + + #[test] + fn test_no_hmr_artifacts_when_disabled() { + let source = "import { Component } from '@angular/core';\n\n@Component({\n selector: 'app-x',\n standalone: true,\n template: '
{{ x }}
',\n})\nexport class XComponent { x = 1; }\n"; + let path = PathBuf::from("/proj/src/x.component.ts"); + let style_ctx = StyleContext::default(); + let result = + compile_component_with_options(source, &path, &style_ctx, &CompileOptions::default()) + .expect("compile"); + assert!(result.hmr.is_none()); + assert!(!result.source.contains("import * as i0")); + } + #[test] fn test_complex_component_roundtrip() { // Exact reproduction of SidenavComponent patterns including: diff --git a/crates/template-compiler/src/rewrite.rs b/crates/template-compiler/src/rewrite.rs index b847169..f9505fe 100644 --- a/crates/template-compiler/src/rewrite.rs +++ b/crates/template-compiler/src/rewrite.rs @@ -13,6 +13,7 @@ pub fn rewrite_source( source: &str, component: &ExtractedComponent, ivy_output: &IvyOutput, + hmr: bool, ) -> NgcResult { let common = DecoratorCommon { decorator_span: component.decorator_span, @@ -20,7 +21,16 @@ pub fn rewrite_source( angular_core_import_span: component.angular_core_import_span, other_angular_core_imports: component.other_angular_core_imports.clone(), }; - rewrite_source_generic(source, &common, ivy_output) + let out = rewrite_source_generic(source, &common, ivy_output)?; + if hmr { + // The HMR initializer (appended later) and `ɵɵreplaceMetadata` need a + // namespace handle for `@angular/core`; the bundler resolves this to + // the synthesized core namespace object. The existing named import is + // left in place for the def's own runtime symbols. + Ok(format!("import * as i0 from '@angular/core';\n{out}")) + } else { + Ok(out) + } } /// Rewrite a TypeScript source string to replace any Angular decorator with @@ -187,7 +197,7 @@ mod tests { fn test_rewrite_removes_decorator() { let component = make_component(); let ivy = make_ivy_output(); - let result = rewrite_source(TEST_SOURCE, &component, &ivy).expect("should rewrite"); + let result = rewrite_source(TEST_SOURCE, &component, &ivy, false).expect("should rewrite"); assert!(!result.contains("@Component")); assert!(result.contains("\u{0275}\u{0275}defineComponent")); assert!(result.contains("class AppComponent")); @@ -198,7 +208,7 @@ mod tests { fn test_rewrite_updates_imports() { let component = make_component(); let ivy = make_ivy_output(); - let result = rewrite_source(TEST_SOURCE, &component, &ivy).expect("should rewrite"); + let result = rewrite_source(TEST_SOURCE, &component, &ivy, false).expect("should rewrite"); assert!(result.contains("\u{0275}\u{0275}defineComponent")); assert!(result.contains("\u{0275}\u{0275}element")); assert!(!result.contains("import { Component }")); @@ -208,7 +218,7 @@ mod tests { fn test_rewrite_inserts_static_fields() { let component = make_component(); let ivy = make_ivy_output(); - let result = rewrite_source(TEST_SOURCE, &component, &ivy).expect("should rewrite"); + let result = rewrite_source(TEST_SOURCE, &component, &ivy, false).expect("should rewrite"); assert!(result.contains("static \u{0275}fac")); assert!(result.contains("static \u{0275}cmp")); // Static fields should be inside the class body From 3986b78ab90e452a16ab0509ce584e93129834d9 Mon Sep 17 00:00:00 2001 From: lukekania Date: Tue, 9 Jun 2026 16:24:34 +0200 Subject: [PATCH 14/20] chore: bump version to 0.10.17 (#145 HMR) --- Cargo.lock | 20 ++++++++++---------- Cargo.toml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 25bd796..07ccdae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -874,7 +874,7 @@ dependencies = [ [[package]] name = "ngc-bundler" -version = "0.10.16" +version = "0.10.17" dependencies = [ "dashmap", "ngc-diagnostics", @@ -899,7 +899,7 @@ dependencies = [ [[package]] name = "ngc-dev-server" -version = "0.10.16" +version = "0.10.17" dependencies = [ "ngc-diagnostics", "rcgen", @@ -912,7 +912,7 @@ dependencies = [ [[package]] name = "ngc-diagnostics" -version = "0.10.16" +version = "0.10.17" dependencies = [ "serde_json", "thiserror", @@ -920,7 +920,7 @@ dependencies = [ [[package]] name = "ngc-linker" -version = "0.10.16" +version = "0.10.17" dependencies = [ "dashmap", "insta", @@ -938,7 +938,7 @@ dependencies = [ [[package]] name = "ngc-npm-resolver" -version = "0.10.16" +version = "0.10.17" dependencies = [ "dashmap", "ngc-diagnostics", @@ -953,7 +953,7 @@ dependencies = [ [[package]] name = "ngc-project-resolver" -version = "0.10.16" +version = "0.10.17" dependencies = [ "dashmap", "glob", @@ -969,7 +969,7 @@ dependencies = [ [[package]] name = "ngc-rs" -version = "0.10.16" +version = "0.10.17" dependencies = [ "base64 0.22.1", "clap", @@ -1003,7 +1003,7 @@ dependencies = [ [[package]] name = "ngc-template-compiler" -version = "0.10.16" +version = "0.10.17" dependencies = [ "insta", "ngc-diagnostics", @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "ngc-ts-transform" -version = "0.10.16" +version = "0.10.17" dependencies = [ "ngc-diagnostics", "oxc_allocator", @@ -1044,7 +1044,7 @@ dependencies = [ [[package]] name = "ngc-watch" -version = "0.10.16" +version = "0.10.17" dependencies = [ "ngc-diagnostics", "notify", diff --git a/Cargo.toml b/Cargo.toml index b76a437..6a8f4c5 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.16" +version = "0.10.17" edition = "2021" license = "MIT OR Apache-2.0" authors = ["lukekania"] From fa1199a97eadff996475f095449fd1b93b1831ea Mon Sep 17 00:00:00 2001 From: lukekania Date: Wed, 10 Jun 2026 09:16:44 +0200 Subject: [PATCH 15/20] feat(builder): accept and forward the dev-server `hmr` option (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust side of HMR landed in PRs #185-#187, but the Architect builder still rejected `hmr` at schema validation (additionalProperties: false) and never forwarded a flag, so `ng serve` users could not reach it. - schemas/dev-server.json: declare `hmr` (boolean, no default) - serve/options.ts: tri-state forwarding — true → --hmr, false → --no-hmr, unset → nothing (binary inherits architect.serve.options.hmr from angular.json) --- packages/builder/schemas/dev-server.json | 4 ++++ .../src/serve/__tests__/options.test.ts | 18 ++++++++++++++++++ packages/builder/src/serve/options.ts | 9 +++++++++ 3 files changed, 31 insertions(+) diff --git a/packages/builder/schemas/dev-server.json b/packages/builder/schemas/dev-server.json index 3396d2f..62c1075 100644 --- a/packages/builder/schemas/dev-server.json +++ b/packages/builder/schemas/dev-server.json @@ -73,6 +73,10 @@ "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." + }, + "hmr": { + "type": "boolean", + "description": "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. TypeScript edits still trigger a full reload. When unset, the spawned binary falls back to `architect.serve.options.hmr` in angular.json (default false, matching today's full-reload behavior)." } }, "additionalProperties": false diff --git a/packages/builder/src/serve/__tests__/options.test.ts b/packages/builder/src/serve/__tests__/options.test.ts index e228107..d583876 100644 --- a/packages/builder/src/serve/__tests__/options.test.ts +++ b/packages/builder/src/serve/__tests__/options.test.ts @@ -226,6 +226,24 @@ describe('translateOptions', () => { ).not.toContain('--headers'); expect(translateOptions(base, '/ws').args).not.toContain('--headers'); }); + + it('forwards hmr: true as --hmr', () => { + const t = translateOptions({ ...base, hmr: true }, '/ws'); + expect(t.args).toContain('--hmr'); + expect(t.args).not.toContain('--no-hmr'); + }); + + it('forwards hmr: false as --no-hmr', () => { + const t = translateOptions({ ...base, hmr: false }, '/ws'); + expect(t.args).toContain('--no-hmr'); + expect(t.args).not.toContain('--hmr'); + }); + + it('omits both hmr flags when hmr is unset so the binary inherits angular.json', () => { + const args = translateOptions(base, '/ws').args; + expect(args).not.toContain('--hmr'); + expect(args).not.toContain('--no-hmr'); + }); }); describe('formatUrl', () => { diff --git a/packages/builder/src/serve/options.ts b/packages/builder/src/serve/options.ts index c22e9b5..6ce4433 100644 --- a/packages/builder/src/serve/options.ts +++ b/packages/builder/src/serve/options.ts @@ -17,6 +17,7 @@ export interface DevServerOptions extends json.JsonObject { servePath: string | null; allowedHosts: string[] | null; headers: { [key: string]: string } | null; + hmr: boolean | null; } export interface TranslatedServeArgs { @@ -95,6 +96,14 @@ export function translateOptions( if (headers !== null) { args.push('--headers', headers); } + // `hmr` is tri-state: an explicit true/false becomes `--hmr`/`--no-hmr` + // (the CLI override flags), while unset forwards nothing so the binary + // falls back to `architect.serve.options.hmr` in angular.json. + if (raw.hmr === true) { + args.push('--hmr'); + } else if (raw.hmr === false) { + args.push('--no-hmr'); + } args.push(...sslArgs); return { From 5059196e74a17099630ab8872ab6c58c4384fdaa Mon Sep 17 00:00:00 2001 From: lukekania Date: Wed, 10 Jun 2026 09:21:42 +0200 Subject: [PATCH 16/20] chore: bump version to 0.10.18 (#145 builder hmr option) --- Cargo.lock | 20 ++++++++++---------- Cargo.toml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 07ccdae..1869cdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -874,7 +874,7 @@ dependencies = [ [[package]] name = "ngc-bundler" -version = "0.10.17" +version = "0.10.18" dependencies = [ "dashmap", "ngc-diagnostics", @@ -899,7 +899,7 @@ dependencies = [ [[package]] name = "ngc-dev-server" -version = "0.10.17" +version = "0.10.18" dependencies = [ "ngc-diagnostics", "rcgen", @@ -912,7 +912,7 @@ dependencies = [ [[package]] name = "ngc-diagnostics" -version = "0.10.17" +version = "0.10.18" dependencies = [ "serde_json", "thiserror", @@ -920,7 +920,7 @@ dependencies = [ [[package]] name = "ngc-linker" -version = "0.10.17" +version = "0.10.18" dependencies = [ "dashmap", "insta", @@ -938,7 +938,7 @@ dependencies = [ [[package]] name = "ngc-npm-resolver" -version = "0.10.17" +version = "0.10.18" dependencies = [ "dashmap", "ngc-diagnostics", @@ -953,7 +953,7 @@ dependencies = [ [[package]] name = "ngc-project-resolver" -version = "0.10.17" +version = "0.10.18" dependencies = [ "dashmap", "glob", @@ -969,7 +969,7 @@ dependencies = [ [[package]] name = "ngc-rs" -version = "0.10.17" +version = "0.10.18" dependencies = [ "base64 0.22.1", "clap", @@ -1003,7 +1003,7 @@ dependencies = [ [[package]] name = "ngc-template-compiler" -version = "0.10.17" +version = "0.10.18" dependencies = [ "insta", "ngc-diagnostics", @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "ngc-ts-transform" -version = "0.10.17" +version = "0.10.18" dependencies = [ "ngc-diagnostics", "oxc_allocator", @@ -1044,7 +1044,7 @@ dependencies = [ [[package]] name = "ngc-watch" -version = "0.10.17" +version = "0.10.18" dependencies = [ "ngc-diagnostics", "notify", diff --git a/Cargo.toml b/Cargo.toml index 6a8f4c5..6bf3f94 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.17" +version = "0.10.18" edition = "2021" license = "MIT OR Apache-2.0" authors = ["lukekania"] From 4859d0e08f77890aeb435038094530606a615c94 Mon Sep 17 00:00:00 2001 From: lukekania Date: Wed, 15 Jul 2026 16:34:34 +0200 Subject: [PATCH 17/20] feat(bundler,compiler): close builder parity gaps for tree-shaking and template listeners Tree-shaking (issue #171): barrel-export npm packages are now shaken to the used exports only, matching @angular/build. Two gaps are fixed in the chunk shake: - Bare package specifiers (`import { debounce } from 'lodash-es'`) now resolve through node_modules, so reachability can follow the barrel re-export edge to the one used leaf instead of pinning the whole package. - The owning package's `sideEffects` field is honoured. A file in a `sideEffects: false` package is treated as free of module-level effects and may be dropped when unreached, even with top-level statements. Without this, lodash-es's `lodash.default.js` (hundreds of top-level `_.x = ...` lines) was pinned as side-effectful and dragged the entire package into the chunk. The shake analysis was reworked from "imported-by-anyone" to a reachability fixpoint over (module, export-name) pairs, with whole-module dead-code elimination for unreachable npm modules. Result on the test app's vendor-treeshake route: the lodash chunk drops from 133 KB to 8.3 KB (@angular/build reference: 6.7 KB), carrying only debounce and its transitive deps. Template listeners: the compiler now matches @angular/build for two constructs that previously produced runtime errors: - `$any()` casts in listener expressions are stripped at compile time instead of emitting a `ctx.$any(...)` call that throws `TypeError`. - Template reference variables read inside a root-level listener (``) resolve via `restoreView` + `reference()` instead of throwing `ReferenceError`. Adds a `read_side_effects` reader in npm-resolver, an end-to-end barrel-treeshake integration test mirroring the lodash-es shape (bare specifier + `sideEffects: false` aggregator), and unit coverage for the new paths. Bumps workspace version to 0.10.19. --- Cargo.lock | 20 +- Cargo.toml | 2 +- crates/bundler/src/concat.rs | 30 +- crates/bundler/src/shake.rs | 425 +++++++++++++++--- .../tests/barrel_treeshake_integration.rs | 203 +++++++++ crates/npm-resolver/src/package_json.rs | 158 +++++++ crates/template-compiler/src/codegen.rs | 200 +++++++++ 7 files changed, 960 insertions(+), 78 deletions(-) create mode 100644 crates/bundler/tests/barrel_treeshake_integration.rs diff --git a/Cargo.lock b/Cargo.lock index 1869cdb..6bfc489 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -874,7 +874,7 @@ dependencies = [ [[package]] name = "ngc-bundler" -version = "0.10.18" +version = "0.10.19" dependencies = [ "dashmap", "ngc-diagnostics", @@ -899,7 +899,7 @@ dependencies = [ [[package]] name = "ngc-dev-server" -version = "0.10.18" +version = "0.10.19" dependencies = [ "ngc-diagnostics", "rcgen", @@ -912,7 +912,7 @@ dependencies = [ [[package]] name = "ngc-diagnostics" -version = "0.10.18" +version = "0.10.19" dependencies = [ "serde_json", "thiserror", @@ -920,7 +920,7 @@ dependencies = [ [[package]] name = "ngc-linker" -version = "0.10.18" +version = "0.10.19" dependencies = [ "dashmap", "insta", @@ -938,7 +938,7 @@ dependencies = [ [[package]] name = "ngc-npm-resolver" -version = "0.10.18" +version = "0.10.19" dependencies = [ "dashmap", "ngc-diagnostics", @@ -953,7 +953,7 @@ dependencies = [ [[package]] name = "ngc-project-resolver" -version = "0.10.18" +version = "0.10.19" dependencies = [ "dashmap", "glob", @@ -969,7 +969,7 @@ dependencies = [ [[package]] name = "ngc-rs" -version = "0.10.18" +version = "0.10.19" dependencies = [ "base64 0.22.1", "clap", @@ -1003,7 +1003,7 @@ dependencies = [ [[package]] name = "ngc-template-compiler" -version = "0.10.18" +version = "0.10.19" dependencies = [ "insta", "ngc-diagnostics", @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "ngc-ts-transform" -version = "0.10.18" +version = "0.10.19" dependencies = [ "ngc-diagnostics", "oxc_allocator", @@ -1044,7 +1044,7 @@ dependencies = [ [[package]] name = "ngc-watch" -version = "0.10.18" +version = "0.10.19" dependencies = [ "ngc-diagnostics", "notify", diff --git a/Cargo.toml b/Cargo.toml index 6bf3f94..36ddd79 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.18" +version = "0.10.19" edition = "2021" license = "MIT OR Apache-2.0" authors = ["lukekania"] diff --git a/crates/bundler/src/concat.rs b/crates/bundler/src/concat.rs index b06b40b..74b7f30 100644 --- a/crates/bundler/src/concat.rs +++ b/crates/bundler/src/concat.rs @@ -210,18 +210,26 @@ pub fn bundle(input: &BundleInput) -> NgcResult { .par_iter() .enumerate() .map(|(idx, chunk)| -> NgcResult<(String, ChunkBundleResult)> { - let unused_exports = if input.options.tree_shake { + let (unused_exports, dead_modules) = if input.options.tree_shake { let externally_used_ref = externally_used_per_chunk.get(idx); - shake::analyze_unused_exports( + // Main/lazy chunk entries are consumed via bootstrap or dynamic + // `import().then(m => m.X)` — invisible to static analysis — so + // keep all their exports. A shared vendor chunk's entry is just + // the first package module; let `externally_used` drive shaking. + let seed_entry_exports = + matches!(chunk.kind, ChunkKind::Main | ChunkKind::Lazy); + let shake = shake::analyze_unused_exports( &chunk.modules, &input.modules, &chunk.entry, &prefix_refs, externally_used_ref, + seed_entry_exports, subpath_ctx, - )? + )?; + (shake.unused_exports, shake.dead_modules) } else { - HashMap::new() + (HashMap::new(), HashSet::new()) }; let chunk_module_set: HashSet = chunk.modules.iter().cloned().collect(); @@ -234,6 +242,7 @@ pub fn bundle(input: &BundleInput) -> NgcResult { per_module_maps: &input.per_module_maps, generate_source_maps: input.options.source_maps, unused_exports: &unused_exports, + dead_modules: &dead_modules, bundled_specifiers: &input.bundled_specifiers, external_specifiers: &input.external_specifiers, chunk_entry: &chunk.entry, @@ -557,6 +566,11 @@ struct ChunkBundleParams<'a> { per_module_maps: &'a HashMap, generate_source_maps: bool, unused_exports: &'a HashMap>, + /// Modules the shaker proved unreachable. ngc-rs drops these from the chunk + /// entirely, but only when they are npm modules — project modules keep the + /// conservative behaviour to stay clear of framework-magic reachability the + /// static analysis can't see. + dead_modules: &'a HashSet, bundled_specifiers: &'a HashSet, /// Specifiers declared external via `externalDependencies` in /// `angular.json`. Imports matching these stay as bare specifiers. @@ -652,6 +666,14 @@ fn bundle_chunk(p: &ChunkBundleParams<'_>) -> NgcResult { let is_npm = file_to_namespace.contains_key(module_path); let file_name = module_path.to_string_lossy(); + // Whole-module dead-code elimination: an unreachable npm module + // (e.g. a barrel package's unused sibling re-exports) contributes + // nothing live to the chunk, so drop it. Restricted to npm + // modules; project modules keep the conservative path. + if is_npm && p.dead_modules.contains(module_path) { + return Ok((None, Vec::new())); + } + if is_npm { // NPM module: wrap in IIFE with namespace isolation. // The wrap closure resolves to canonical `__ns_*` names diff --git a/crates/bundler/src/shake.rs b/crates/bundler/src/shake.rs index e2d4708..8020a55 100644 --- a/crates/bundler/src/shake.rs +++ b/crates/bundler/src/shake.rs @@ -27,41 +27,84 @@ pub struct SubpathImportContext<'a> { pub export_conditions: &'a [&'a str], } +/// A re-export edge: `export { as } from ''`. +/// +/// Unlike a plain import, a re-export only *consumes* `source`'s `local` +/// binding when some other module actually imports this module's `exported` +/// name. Tracking the edge (rather than eagerly marking `source` used) is what +/// lets a barrel package like `lodash-es` tree-shake: importing `{ debounce }` +/// keeps `debounce.js` while every sibling re-export stays dead. +#[derive(Clone)] +struct ReExport { + source: String, + local: String, + exported: String, +} + /// Information about a module's exports and imports for tree shaking analysis. struct ModuleInfo { /// Names exported by this module. exported_names: HashSet, /// Names imported from local modules: maps source specifier -> set of imported names. local_imports: HashMap>, + /// Re-export edges (`export { x as y } from './z'`), tracked separately so + /// usage propagates only for the re-exported names a consumer reaches. + reexports: Vec, /// Whether this module has top-level side effects (expression statements, etc.). has_side_effects: bool, } -/// Analyze export usage across modules in a chunk and return unused exports per module. +/// Result of per-chunk tree-shake analysis. +pub struct ChunkShake { + /// Map from module path to the set of export names that nothing reachable + /// in the chunk consumes. The caller trims these export bridges/declarations. + pub unused_exports: HashMap>, + /// Modules that are unreachable from the chunk's live roots and carry no + /// side effects — their whole body can be dropped from the chunk. The + /// caller decides which of these are safe to elide (ngc-rs limits this to + /// npm modules). Never includes the entry or any side-effectful module. + pub dead_modules: HashSet, +} + +/// Analyze export usage across modules in a chunk via reachability. +/// +/// Starting from the chunk's live roots — the entry module's exports, any +/// `externally_used` names (consumed cross-chunk), and every side-effectful +/// module — this walks import and re-export edges to the fixpoint of reachable +/// `(module, export)` pairs. Anything not reached is unused; a module reached +/// by nothing is dead and can be dropped whole. /// -/// Returns a map from module path to the set of export names that are never imported -/// by any other module in the chunk. The entry module's exports are always considered used. +/// Reachability (rather than the older "imported by any module in the chunk") +/// is what lets a barrel package tree-shake: `lodash-es`' `lodash.default.js` +/// imports every method, but it is only reached through the barrel's `default` +/// re-export — which no consumer imports — so it and its transitive imports +/// stay dead while `import { debounce }` keeps just `debounce.js` and its deps. /// -/// Modules with no used exports, no side effects, and that are not the entry point -/// are indicated by having ALL their exports listed as unused — the caller can -/// choose to drop them entirely. +/// `externally_used` optionally carries a flat set of names that must be +/// preserved across every module in the chunk regardless of intra-chunk usage. +/// Callers pass this for the main chunk to reflect symbols consumed cross-chunk +/// by lazy chunks — such consumption is invisible to the per-chunk analysis and +/// would otherwise leave dangling names in the final `export { ... }` block. /// -/// `externally_used` optionally carries a flat set of names that must be preserved -/// across every module in the chunk regardless of intra-chunk usage. Callers pass -/// this for the main chunk to reflect symbols consumed cross-chunk by lazy chunks -/// — such consumption is invisible to the per-chunk analysis below and would -/// otherwise leave dangling names in the bundler's final `export { ... }` block. +/// `seed_entry_exports` controls whether *all* of the entry module's exports +/// are treated as roots. Main/lazy chunks set this: their entry is consumed via +/// bootstrap or `import('./route').then(m => m.X)` — a dynamic property access +/// the static graph can't see, so every entry export must survive. Shared +/// vendor chunks set it `false`: their "entry" is merely the lexicographically +/// first package module, so seeding its exports would pin the whole package; +/// `externally_used` is the real consumption signal there. pub fn analyze_unused_exports( module_paths: &[PathBuf], all_code: &HashMap, entry: &PathBuf, local_prefixes: &[&str], externally_used: Option<&HashSet>, + seed_entry_exports: bool, subpath_ctx: Option>, -) -> NgcResult>> { +) -> NgcResult { // Step 1: Parse each module in parallel and collect export/import info. // `analyze_module` only reads its inputs, so per-module work is independent. - let module_infos: HashMap = module_paths + let mut module_infos: HashMap = module_paths .par_iter() .filter_map(|path| all_code.get(path).map(|code| (path, code))) .map(|(path, code)| -> NgcResult<(PathBuf, ModuleInfo)> { @@ -70,67 +113,174 @@ pub fn analyze_unused_exports( }) .collect::>>()?; - // Step 2: Build usage map — which exports are actually referenced - let mut used_exports: HashMap> = HashMap::new(); + // Honour each npm package's `sideEffects` field. A file inside a package + // marked `sideEffects: false` (or not matched by a `sideEffects` glob) is + // guaranteed free of module-level effects, so it may be dropped whole when + // none of its exports are reached — even if it has top-level statements. + // Without this, barrels like lodash-es's `lodash.default.js` (hundreds of + // top-level `_.x = ...` assignments) are pinned as side-effectful and drag + // the entire package into the chunk. + let mut side_effects_cache: HashMap = + HashMap::new(); + for (path, info) in module_infos.iter_mut() { + if !info.has_side_effects { + continue; + } + let Some(pkg_root) = npm_package_root(path) else { + continue; + }; + let classifier = side_effects_cache + .entry(pkg_root.clone()) + .or_insert_with(|| ngc_npm_resolver::package_json::read_side_effects(&pkg_root)); + if classifier.is_free(&pkg_root, path) { + info.has_side_effects = false; + } + } - // Entry module exports are always considered used - if let Some(info) = module_infos.get(entry) { - used_exports - .entry(entry.clone()) - .or_default() - .extend(info.exported_names.iter().cloned()); + // Resolve a specifier appearing in `from_module` to a chunk module path. + let resolve = |specifier: &str, from_module: &Path| -> Option { + resolve_local_specifier( + specifier, + from_module, + module_paths, + local_prefixes, + subpath_ctx, + ) + }; + + // Step 2: Reachability fixpoint over (module, export-name) pairs. + // + // `used[m]` accumulates the export names of `m` that are reached. + // `reachable` holds modules whose body must be kept (any used export, a + // side effect, or the entry). A worklist drives propagation: when a module + // first becomes reachable we mark all its direct imports used (and their + // targets reachable); when an export name becomes used we pass it through + // any matching re-export edge to the upstream module. + let mut used: HashMap> = HashMap::new(); + let mut reachable: HashSet = HashSet::new(); + // Modules whose direct imports still need to be propagated. + let mut import_queue: Vec = Vec::new(); + // (module, name) uses whose re-export pass-through still needs propagating. + let mut use_queue: Vec<(PathBuf, String)> = Vec::new(); + + let mark_reachable = + |module: &PathBuf, reachable: &mut HashSet, import_queue: &mut Vec| { + if reachable.insert(module.clone()) { + import_queue.push(module.clone()); + } + }; + + // Seed: the entry is always reachable (its body and imports are kept). + // Whether its *exports* are all roots depends on the chunk kind. + if module_infos.contains_key(entry) { + mark_reachable(entry, &mut reachable, &mut import_queue); + if seed_entry_exports { + if let Some(info) = module_infos.get(entry) { + for name in &info.exported_names { + if used.entry(entry.clone()).or_default().insert(name.clone()) { + use_queue.push((entry.clone(), name.clone())); + } + } + } + } + } + // Seed: side-effectful modules are kept (their top-level effects must run); + // and externally-used exports are roots. + for (module_path, info) in &module_infos { + if info.has_side_effects { + mark_reachable(module_path, &mut reachable, &mut import_queue); + } + if let Some(ext) = externally_used { + for name in &info.exported_names { + if ext.contains(name) + && used + .entry(module_path.clone()) + .or_default() + .insert(name.clone()) + { + mark_reachable(module_path, &mut reachable, &mut import_queue); + use_queue.push((module_path.clone(), name.clone())); + } + } + } } - // For each module, check what it imports from other local modules - for (importer_path, info) in &module_infos { - for (specifier, imported_names) in &info.local_imports { - // Resolve specifier to a module path - if let Some(target_path) = resolve_local_specifier( - specifier, - importer_path, - module_paths, - local_prefixes, - subpath_ctx, - ) { - debug!( - importer = %importer_path.display(), - specifier = specifier, - target = %target_path.display(), - names = ?imported_names, - "tree shake: resolved import" - ); - used_exports - .entry(target_path) + // Drain both worklists to a fixpoint. + while !import_queue.is_empty() || !use_queue.is_empty() { + while let Some(module_path) = import_queue.pop() { + let Some(info) = module_infos.get(&module_path) else { + continue; + }; + // A reachable module pulls in every name it directly imports. + for (specifier, imported_names) in &info.local_imports { + let Some(target) = resolve(specifier, &module_path) else { + continue; + }; + mark_reachable(&target, &mut reachable, &mut import_queue); + for name in imported_names { + if used + .entry(target.clone()) + .or_default() + .insert(name.clone()) + { + use_queue.push((target.clone(), name.clone())); + } + } + } + } + while let Some((module_path, name)) = use_queue.pop() { + let Some(info) = module_infos.get(&module_path) else { + continue; + }; + // Pass the use through any re-export edge for this name. + for re in &info.reexports { + if re.exported != name { + continue; + } + let Some(target) = resolve(&re.source, &module_path) else { + continue; + }; + mark_reachable(&target, &mut reachable, &mut import_queue); + if used + .entry(target.clone()) .or_default() - .extend(imported_names.iter().cloned()); + .insert(re.local.clone()) + { + use_queue.push((target.clone(), re.local.clone())); + } } } } - // Step 3: Compute unused exports + // Step 3: Derive unused exports and dead modules from the reachable set. let mut unused: HashMap> = HashMap::new(); + let mut dead_modules: HashSet = HashSet::new(); for (module_path, info) in &module_infos { - // Skip entry module — its exports are always kept - if module_path == entry { + if module_path == entry || info.has_side_effects { + // Entry and side-effectful modules are kept verbatim. continue; } - // Skip modules with side effects — they must be kept - if info.has_side_effects { + if !reachable.contains(module_path) { + // Nothing reaches this module: drop its whole body. + debug!(module = %module_path.display(), "tree shake: dead module"); + dead_modules.insert(module_path.clone()); + // Also report every export as unused so any consumer-side bridge + // referencing it is trimmed (defence in depth; there should be none). + if !info.exported_names.is_empty() { + unused.insert(module_path.clone(), info.exported_names.clone()); + } continue; } - let used = used_exports.get(module_path); - let mut unused_names = HashSet::new(); - - for name in &info.exported_names { - let is_used = used.is_some_and(|u| u.contains(name)); - let is_externally_used = externally_used.is_some_and(|set| set.contains(name)); - if !is_used && !is_externally_used { - unused_names.insert(name.clone()); - } - } + let used_here = used.get(module_path); + let unused_names: HashSet = info + .exported_names + .iter() + .filter(|name| !used_here.is_some_and(|u| u.contains(*name))) + .cloned() + .collect(); if !unused_names.is_empty() { debug!( @@ -142,7 +292,10 @@ pub fn analyze_unused_exports( } } - Ok(unused) + Ok(ChunkShake { + unused_exports: unused, + dead_modules, + }) } /// Parse a module and extract export/import information for tree shaking. @@ -158,6 +311,7 @@ fn analyze_module(code: &str, path: &Path) -> NgcResult { let mut exported_names = HashSet::new(); let mut local_imports: HashMap> = HashMap::new(); + let mut reexports: Vec = Vec::new(); let mut has_side_effects = false; for stmt in &parsed.program.body { @@ -194,8 +348,20 @@ fn analyze_module(code: &str, path: &Path) -> NgcResult { if let Some(decl) = &export.declaration { collect_declaration_names(decl, &mut exported_names); } + let source = export.source.as_ref().map(|s| s.value.to_string()); for spec in &export.specifiers { - exported_names.insert(spec.exported.name().to_string()); + let exported = spec.exported.name().to_string(); + exported_names.insert(exported.clone()); + // `export { x as y } from './z'` is a re-export edge, not + // a local binding. Record it so reachability can pass the + // use of `y` through to `./z`'s `x` only when reached. + if let Some(src) = &source { + reexports.push(ReExport { + source: src.clone(), + local: spec.local.name().to_string(), + exported, + }); + } } } ModuleDeclaration::ExportDefaultDeclaration(export) => { @@ -226,6 +392,7 @@ fn analyze_module(code: &str, path: &Path) -> NgcResult { Ok(ModuleInfo { exported_names, local_imports, + reexports, has_side_effects, }) } @@ -254,6 +421,33 @@ fn collect_declaration_names(decl: &oxc_ast::ast::Declaration, names: &mut HashS } } +/// Given a path inside `node_modules`, return the package's root directory +/// (`.../node_modules/` or `.../node_modules/@scope/`). Returns +/// `None` for paths that aren't inside a `node_modules` tree. +fn npm_package_root(path: &Path) -> Option { + let components: Vec<_> = path.components().collect(); + // Find the last `node_modules` segment (handles nested node_modules). + let nm_idx = components + .iter() + .rposition(|c| c.as_os_str() == "node_modules")?; + let first = components.get(nm_idx + 1)?; + // Scoped packages span two segments: `@scope/name`. + let take = if first.as_os_str().to_string_lossy().starts_with('@') { + 2 + } else { + 1 + }; + let end = nm_idx + 1 + take; + if components.len() < end { + return None; + } + let mut root = PathBuf::new(); + for c in &components[..end] { + root.push(c.as_os_str()); + } + Some(root) +} + /// Try to resolve a local import specifier to a module path. /// /// This is a best-effort resolution — it checks if the specifier starts with @@ -283,6 +477,27 @@ fn resolve_local_specifier( return module_paths.iter().find(|p| **p == canonical).cloned(); } + // Bare npm specifier (e.g. `lodash-es`, `@angular/core`, `lodash-es/debounce`). + // Resolve it through node_modules so reachability can follow a chunk-local + // barrel re-export edge — without this, `import { debounce } from 'lodash-es'` + // never reaches `lodash-es/lodash.js` and the whole package is pinned. + // Cross-chunk bare imports resolve to a path outside `module_paths` and fall + // through to `None` (handled by the `externally_used` mechanism instead). + let is_bare = !specifier.starts_with('.') + && !specifier.starts_with('/') + && !specifier.starts_with('#'); + if is_bare { + let ctx = subpath_ctx?; + let resolved = ngc_npm_resolver::resolve::resolve_bare_specifier( + specifier, + ctx.root_dir, + ctx.export_conditions, + ) + .ok()?; + let canonical = resolved.canonicalize().unwrap_or(resolved); + return module_paths.iter().find(|p| **p == canonical).cloned(); + } + let is_local = local_prefixes.iter().any(|p| specifier.starts_with(p)); if !is_local { return None; @@ -555,11 +770,12 @@ mod tests { &PathBuf::from("/root/main.js"), &["."], None, + true, None, ) .expect("should analyze"); - let utils_unused = result.get(&PathBuf::from("/root/utils.js")); + let utils_unused = result.unused_exports.get(&PathBuf::from("/root/utils.js")); assert!(utils_unused.is_some(), "utils should have unused exports"); assert!( utils_unused.expect("checked").contains("unused"), @@ -587,12 +803,13 @@ mod tests { &PathBuf::from("/root/main.ts"), &["."], None, + true, None, ) .expect("should analyze"); assert!( - !result.contains_key(&PathBuf::from("/root/main.ts")), + !result.unused_exports.contains_key(&PathBuf::from("/root/main.ts")), "entry module exports should never be marked unused" ); } @@ -620,12 +837,13 @@ mod tests { &PathBuf::from("/root/main.ts"), &["."], None, + true, None, ) .expect("should analyze"); assert!( - !result.contains_key(&PathBuf::from("/root/side.ts")), + !result.unused_exports.contains_key(&PathBuf::from("/root/side.ts")), "side-effect module should not have unused exports listed" ); } @@ -659,17 +877,98 @@ mod tests { &PathBuf::from("/root/main.js"), &["."], Some(&externally_used), + false, None, ) .expect("should analyze"); - let svc_unused = result.get(&PathBuf::from("/root/svc.js")); + let svc_unused = result.unused_exports.get(&PathBuf::from("/root/svc.js")); assert!( svc_unused.is_none() || !svc_unused.expect("checked").contains("AnalyticsService"), "externally-used export must not be flagged unused" ); } + #[test] + fn test_barrel_reexport_shakes_to_used_method() { + // Miniature of the lodash-es shape: a barrel re-exports two leaf + // methods plus a `default` aggregator that imports every method. The + // consumer imports only `debounce`. Reachability must keep the barrel, + // `debounce`, and `debounce`'s transitive dep, while dropping the unused + // `throttle` leaf and the aggregator (reached only via the unused + // `default` re-export) — even though the aggregator imports `throttle`. + let mut modules: HashMap = HashMap::new(); + modules.insert( + PathBuf::from("/lib/main.js"), + "import { debounce } from './barrel.js';\ndebounce();\n".into(), + ); + modules.insert( + PathBuf::from("/lib/barrel.js"), + "export { default as debounce } from './debounce.js';\n\ + export { default as throttle } from './throttle.js';\n\ + export { default } from './agg.js';\n" + .into(), + ); + modules.insert( + PathBuf::from("/lib/debounce.js"), + "import helper from './helper.js';\nfunction debounce(){return helper();}\nexport default debounce;\n".into(), + ); + modules.insert( + PathBuf::from("/lib/throttle.js"), + "function throttle(){}\nexport default throttle;\n".into(), + ); + modules.insert( + PathBuf::from("/lib/agg.js"), + "import debounce from './debounce.js';\nimport throttle from './throttle.js';\nexport default { debounce, throttle };\n".into(), + ); + modules.insert( + PathBuf::from("/lib/helper.js"), + "function helper(){}\nexport default helper;\n".into(), + ); + + let paths: Vec = modules.keys().cloned().collect(); + let result = analyze_unused_exports( + &paths, + &modules, + &PathBuf::from("/lib/main.js"), + &["."], + None, + true, + None, + ) + .expect("should analyze"); + + let dead = &result.dead_modules; + assert!( + dead.contains(&PathBuf::from("/lib/throttle.js")), + "unused leaf throttle.js must be dead: {dead:?}" + ); + assert!( + dead.contains(&PathBuf::from("/lib/agg.js")), + "aggregator reached only via unused `default` re-export must be dead: {dead:?}" + ); + assert!( + !dead.contains(&PathBuf::from("/lib/debounce.js")), + "used method debounce.js must be kept" + ); + assert!( + !dead.contains(&PathBuf::from("/lib/helper.js")), + "debounce's transitive dep helper.js must be kept" + ); + assert!( + !dead.contains(&PathBuf::from("/lib/barrel.js")), + "barrel.js is the resolution entry for the import and must be kept" + ); + // The barrel keeps only the `debounce` bridge; throttle/default are unused. + let barrel_unused = result + .unused_exports + .get(&PathBuf::from("/lib/barrel.js")) + .expect("barrel should have unused re-exports"); + assert!(barrel_unused.contains("throttle")); + assert!(barrel_unused.contains("default")); + assert!(!barrel_unused.contains("debounce")); + } + #[test] fn test_collect_cross_chunk_used_names_per_provider_dotted_filename() { // Regression: resolve_local_specifier previously used `with_extension`, diff --git a/crates/bundler/tests/barrel_treeshake_integration.rs b/crates/bundler/tests/barrel_treeshake_integration.rs new file mode 100644 index 0000000..87a912b --- /dev/null +++ b/crates/bundler/tests/barrel_treeshake_integration.rs @@ -0,0 +1,203 @@ +//! End-to-end tree-shaking of a barrel-export npm package — issue #171. +//! +//! Reproduces the real lodash-es shape that a per-provider vendor shake alone +//! did not handle: +//! +//! - A lazy route imports a SINGLE named export via a BARE specifier +//! (`import { debounce } from 'barrel-pkg'`). +//! - The package entry is a pure re-export barrel +//! (`export { default as debounce } from './debounce.js'`, one line per +//! method) that ALSO re-exports a `default` aggregator. +//! - The `default` aggregator (`lodash.default.js`-style) imports every method +//! and has hundreds of top-level assignment statements — i.e. it looks +//! side-effectful — but the package declares `"sideEffects": false`. +//! +//! Correct output: only `debounce` and its transitive deps survive; the unused +//! `throttle` leaf and the side-effect-free-but-unreached aggregator are +//! dropped. Before the fix the aggregator's top-level statements pinned it as +//! side-effectful, dragging the entire package into the chunk. + +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use ngc_bundler::{bundle, BundleInput, BundleOptions}; +use ngc_npm_resolver::package_json::DEVELOPMENT_BROWSER_CONDITIONS; +use ngc_npm_resolver::resolve_npm_dependencies; +use ngc_project_resolver::resolve_project; +use tempfile::tempdir; + +fn build_barrel_project(root: &std::path::Path) -> BundleInput { + fs::write( + root.join("tsconfig.json"), + r#"{ "include": ["src/**/*.ts"], "exclude": [] }"#, + ) + .expect("write tsconfig"); + fs::write( + root.join("package.json"), + r#"{ "name": "barrel-fixture", "dependencies": { "barrel-pkg": "1.0.0" } }"#, + ) + .expect("write package.json"); + + let src = root.join("src"); + fs::create_dir_all(&src).expect("create src"); + + // main.ts lazily loads the route so it becomes its own chunk. + fs::write( + src.join("main.ts"), + "function load(){return import('./route');}\nconsole.log(load);\n", + ) + .expect("write main.ts"); + + // The route imports ONE method via the bare package specifier. + fs::write( + src.join("route.ts"), + "import { debounce } from 'barrel-pkg';\nexport const R = () => debounce(() => {});\n", + ) + .expect("write route.ts"); + + // barrel-pkg: sideEffects:false, entry is a pure re-export barrel. + let pkg_dir = root.join("node_modules").join("barrel-pkg"); + fs::create_dir_all(&pkg_dir).expect("create pkg dir"); + fs::write( + pkg_dir.join("package.json"), + r#"{ "name": "barrel-pkg", "version": "1.0.0", "module": "barrel.js", "main": "barrel.js", "sideEffects": false }"#, + ) + .expect("write pkg package.json"); + fs::write( + pkg_dir.join("barrel.js"), + "export { default as debounce } from './debounce.js';\n\ + export { default as throttle } from './throttle.js';\n\ + export { default } from './aggregator.js';\n", + ) + .expect("write barrel.js"); + // debounce depends on a shared helper. + fs::write( + pkg_dir.join("debounce.js"), + "import helper from './helper.js';\n\ + function debounce(fn){ return helper(fn); }\n\ + export default debounce;\n", + ) + .expect("write debounce.js"); + fs::write( + pkg_dir.join("throttle.js"), + "function throttle(fn){ return fn; }\nexport default throttle;\n", + ) + .expect("write throttle.js"); + fs::write( + pkg_dir.join("helper.js"), + "function helper(fn){ return fn; }\nexport default helper;\n", + ) + .expect("write helper.js"); + // The aggregator LOOKS side-effectful (top-level statements) and imports + // every method — but sideEffects:false means it can be dropped when unused. + fs::write( + pkg_dir.join("aggregator.js"), + "import debounce from './debounce.js';\n\ + import throttle from './throttle.js';\n\ + var _ = {};\n\ + _.debounce = debounce;\n\ + _.throttle = throttle;\n\ + export default _;\n", + ) + .expect("write aggregator.js"); + + let file_graph = resolve_project(&root.join("tsconfig.json")).expect("resolve project"); + let entry = file_graph + .entry_points + .iter() + .find(|p| p.file_name().is_some_and(|n| n == "main.ts")) + .cloned() + .expect("main.ts should be an entry point"); + + let bare_specs: Vec = file_graph.npm_import_sites.keys().cloned().collect(); + let npm = resolve_npm_dependencies(&bare_specs, root, DEVELOPMENT_BROWSER_CONDITIONS) + .expect("npm resolution"); + + let mut graph = file_graph.graph; + let mut path_index = file_graph.path_index; + for path in npm.modules.keys() { + if !path_index.contains_key(path) { + let idx = graph.add_node(path.clone()); + path_index.insert(path.clone(), idx); + } + } + // Bare specifier edges: route.ts -> barrel.js entry. + for (spec, sites) in &file_graph.npm_import_sites { + if let Some(target_path) = npm + .modules + .keys() + .find(|p| p.to_string_lossy().contains(&format!("/{spec}/barrel.js"))) + { + let to_idx = path_index[target_path]; + for (from_file, kind) in sites { + if let Some(&from_idx) = path_index.get(from_file) { + graph.add_edge(from_idx, to_idx, *kind); + } + } + } + } + // Internal npm edges (barrel -> leaves, aggregator -> leaves). + for (from, to, kind) in &npm.edges { + if let (Some(&f), Some(&t)) = (path_index.get(from), path_index.get(to)) { + graph.add_edge(f, t, *kind); + } + } + + let mut modules: HashMap = HashMap::new(); + for idx in graph.node_indices() { + let path = &graph[idx]; + let source = npm + .modules + .get(path) + .cloned() + .or_else(|| fs::read_to_string(path).ok()) + .unwrap_or_else(|| panic!("source missing for {}", path.display())); + modules.insert(path.clone(), source); + } + + BundleInput { + modules, + graph, + entry, + local_prefixes: vec![".".to_string()], + root_dir: root.to_path_buf(), + options: BundleOptions { + tree_shake: true, + ..BundleOptions::default() + }, + per_module_maps: HashMap::new(), + bundled_specifiers: npm.resolved_specifiers.clone(), + external_specifiers: Default::default(), + export_conditions: Vec::new(), + } +} + +#[test] +fn barrel_pkg_shakes_to_used_export_only() { + let temp = tempdir().expect("create temp dir"); + let input = build_barrel_project(temp.path()); + + let output = bundle(&input).expect("bundle succeeds"); + + // The lazy route chunk carries the used method and its helper... + let all_code: String = output.chunks.values().cloned().collect::>().join("\n"); + assert!( + all_code.contains("function debounce"), + "used export `debounce` must survive" + ); + assert!( + all_code.contains("function helper"), + "debounce's transitive dep `helper` must survive" + ); + // ...but NOT the unused leaf, nor the side-effect-free-but-unreached + // aggregator that would otherwise drag the whole package in. + assert!( + !all_code.contains("function throttle"), + "unused leaf `throttle` must be tree-shaken out:\n{all_code}" + ); + assert!( + !all_code.contains("_.throttle = throttle"), + "unreached `sideEffects:false` aggregator must be dropped:\n{all_code}" + ); +} diff --git a/crates/npm-resolver/src/package_json.rs b/crates/npm-resolver/src/package_json.rs index e755c0a..65e0a2c 100644 --- a/crates/npm-resolver/src/package_json.rs +++ b/crates/npm-resolver/src/package_json.rs @@ -36,6 +36,119 @@ pub fn conditions_for_configuration(configuration: Option<&str>) -> &'static [&' } } +/// Whether a package (given its directory) declares itself free of module-level +/// side effects, per the `sideEffects` field. +/// +/// Returns, for the package rooted at `pkg_dir`, a classifier for its files: +/// - `sideEffects: false` → every file is side-effect-free (`true` for all). +/// - `sideEffects: [globs]` → a file is side-effect-free *unless* its path +/// matches one of the globs (matched files are the ones that DO have effects). +/// - absent / `true` → nothing is guaranteed side-effect-free. +/// +/// This mirrors how esbuild / webpack treat the field for tree shaking: a +/// side-effect-free module whose exports are all unused can be dropped whole, +/// even if it has top-level statements (e.g. lodash-es's `lodash.default.js` +/// builds its `_` object with hundreds of top-level assignments yet the whole +/// package is marked `sideEffects: false`). +pub fn read_side_effects(pkg_dir: &Path) -> SideEffects { + let pkg_json_path = pkg_dir.join("package.json"); + let Ok(content) = std::fs::read_to_string(&pkg_json_path) else { + return SideEffects::Unknown; + }; + let Ok(pkg) = serde_json::from_str::(&content) else { + return SideEffects::Unknown; + }; + match pkg.get("sideEffects") { + Some(serde_json::Value::Bool(false)) => SideEffects::None, + Some(serde_json::Value::Array(globs)) => SideEffects::Only( + globs + .iter() + .filter_map(|g| g.as_str().map(|s| s.to_string())) + .collect(), + ), + _ => SideEffects::Unknown, + } +} + +/// Classification of a package's `sideEffects` declaration. +#[derive(Debug, Clone)] +pub enum SideEffects { + /// No `sideEffects` field, or `sideEffects: true` — assume effects present. + Unknown, + /// `sideEffects: false` — every file in the package is side-effect-free. + None, + /// `sideEffects: [globs]` — only files matching a glob have side effects. + Only(Vec), +} + +impl SideEffects { + /// Whether the file at `path` (inside the package rooted at `pkg_dir`) is + /// guaranteed free of module-level side effects. + pub fn is_free(&self, pkg_dir: &Path, path: &Path) -> bool { + match self { + SideEffects::Unknown => false, + SideEffects::None => true, + SideEffects::Only(globs) => { + let rel = path.strip_prefix(pkg_dir).unwrap_or(path); + let rel_str = rel.to_string_lossy().replace('\\', "/"); + // A file is side-effect-free unless it matches a listed glob. + !globs.iter().any(|g| glob_matches(g, &rel_str)) + } + } + } +} + +/// Minimal glob matcher for `sideEffects` array entries. Supports a leading +/// `./`, a `**/` prefix (any directory depth), and `*` (any run of non-slash +/// characters) — enough for the `"*.css"`, `"./src/**/*.js"` forms packages use. +fn glob_matches(glob: &str, path: &str) -> bool { + let g = glob.strip_prefix("./").unwrap_or(glob); + // A bare `**/x` or `*.ext` pattern should match at any directory depth. + if let Some(suffix) = g.strip_prefix("**/") { + if path.rsplit('/').next().is_some_and(|base| simple_glob(suffix, base)) { + return true; + } + return simple_glob(suffix, path); + } + if !g.contains('/') { + // No directory component: match against the basename at any depth. + if let Some(base) = path.rsplit('/').next() { + return simple_glob(g, base); + } + } + simple_glob(g, path) +} + +/// Match a single path segment pattern where `*` is any run of non-`/` chars. +fn simple_glob(pattern: &str, text: &str) -> bool { + let parts: Vec<&str> = pattern.split('*').collect(); + if parts.len() == 1 { + return pattern == text; + } + let mut pos = 0usize; + for (i, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + if i == 0 { + if !text[pos..].starts_with(part) { + return false; + } + pos += part.len(); + } else if i == parts.len() - 1 { + // Final literal must match the end. + if !text[pos..].ends_with(part) { + return false; + } + } else if let Some(found) = text[pos..].find(part) { + pos += found + part.len(); + } else { + return false; + } + } + true +} + /// Resolve the ESM entry point for a package given its directory and a subpath. /// /// Follows the Node.js module resolution algorithm: @@ -330,6 +443,51 @@ mod tests { const DEV: &[&str] = DEVELOPMENT_BROWSER_CONDITIONS; const PROD: &[&str] = PRODUCTION_BROWSER_CONDITIONS; + #[test] + fn side_effects_false_marks_every_file_free() { + let tmp = std::env::temp_dir().join(format!("ngc-se-false-{}", std::process::id())); + let pkg_dir = tmp.join("node_modules/lodash-es"); + fs::create_dir_all(&pkg_dir).unwrap(); + fs::write(pkg_dir.join("package.json"), r#"{"sideEffects": false}"#).unwrap(); + let se = read_side_effects(&pkg_dir); + assert!(matches!(se, SideEffects::None)); + assert!(se.is_free(&pkg_dir, &pkg_dir.join("lodash.default.js"))); + assert!(se.is_free(&pkg_dir, &pkg_dir.join("debounce.js"))); + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn side_effects_absent_or_true_is_unknown() { + let tmp = std::env::temp_dir().join(format!("ngc-se-unknown-{}", std::process::id())); + let pkg_dir = tmp.join("node_modules/pkg"); + fs::create_dir_all(&pkg_dir).unwrap(); + fs::write(pkg_dir.join("package.json"), r#"{"name": "pkg"}"#).unwrap(); + let se = read_side_effects(&pkg_dir); + assert!(matches!(se, SideEffects::Unknown)); + assert!(!se.is_free(&pkg_dir, &pkg_dir.join("index.js"))); + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn side_effects_glob_array_matches_only_listed_files() { + let tmp = std::env::temp_dir().join(format!("ngc-se-glob-{}", std::process::id())); + let pkg_dir = tmp.join("node_modules/pkg"); + fs::create_dir_all(&pkg_dir).unwrap(); + fs::write( + pkg_dir.join("package.json"), + r#"{"sideEffects": ["*.css", "./src/polyfill.js"]}"#, + ) + .unwrap(); + let se = read_side_effects(&pkg_dir); + // Listed files have side effects → NOT free. + assert!(!se.is_free(&pkg_dir, &pkg_dir.join("dist/styles.css"))); + assert!(!se.is_free(&pkg_dir, &pkg_dir.join("src/polyfill.js"))); + // Everything else is free. + assert!(se.is_free(&pkg_dir, &pkg_dir.join("src/index.js"))); + assert!(se.is_free(&pkg_dir, &pkg_dir.join("debounce.js"))); + fs::remove_dir_all(&tmp).ok(); + } + fn create_mock_package(dir: &Path, name: &str, pkg_json: &str, files: &[(&str, &str)]) { let pkg_dir = dir.join("node_modules").join(name); fs::create_dir_all(&pkg_dir).unwrap(); diff --git a/crates/template-compiler/src/codegen.rs b/crates/template-compiler/src/codegen.rs index 638f039..f9c5e23 100644 --- a/crates/template-compiler/src/codegen.rs +++ b/crates/template-compiler/src/codegen.rs @@ -107,6 +107,17 @@ struct IvyCodegen { /// multi-slot projection (``) is /// not yet implemented. has_projection: bool, + /// Set once a root-level (depth-0) listener reads a template reference and + /// therefore needs `ɵɵrestoreView(_r)` to call `ɵɵreference(slot)`. When + /// set, `generate_ivy` prepends `const _r = ɵɵgetCurrentView();` to the + /// root template's creation block (matching `ng build`, which captures the + /// view at the head of `rf & 1`). + root_saved_view_needed: bool, + /// `true` while walking an old-style structural-directive (`*ngIf` / + /// `*ngFor`) child template. Its listeners run inside a separate template + /// function, so they must not be treated as root-level even though they + /// carry an empty `scope_stack`. + in_child_template: bool, } struct ChildTemplate { @@ -148,6 +159,8 @@ pub fn generate_ivy( namespace_state: Namespace::Html, namespace_stack: vec![Namespace::Html], has_projection: false, + root_saved_view_needed: false, + in_child_template: false, }; gen.ivy_imports @@ -165,6 +178,12 @@ pub fn generate_ivy( let mut template_body = String::new(); if !gen.creation.is_empty() { template_body.push_str(" if (rf & 1) {\n"); + // A root-level listener that reads a template reference needs the saved + // view restored before `ɵɵreference(slot)`. Capture it once at the head + // of the creation block (mirrors `ng build`). + if gen.root_saved_view_needed { + template_body.push_str(" const _r = \u{0275}\u{0275}getCurrentView();\n"); + } // When the template uses ``, the runtime needs // `ɵɵprojectionDef()` to run once at the head of the create // block so it can stash the projected children's TNodes onto @@ -763,6 +782,19 @@ impl IvyCodegen { "\u{0275}\u{0275}listener('{}', function($event) {{ {listener_preamble}{compiled_handler} }});", name, )); + } else if let Some(prelude) = + self.root_listener_ref_prelude(&compiled_handler) + { + // Root-level listener (depth 0) reading a template + // reference: it must restore the view and resolve + // the ref via ɵɵreference(slot), just like an + // embedded-view listener. Without this the handler + // sees a bare, undeclared identifier and throws + // `ReferenceError` at runtime. + self.creation.push(format!( + "\u{0275}\u{0275}listener('{}', function($event) {{ {prelude}{compiled_handler} }});", + name, + )); } else { self.creation.push(format!( "\u{0275}\u{0275}listener('{}', function($event) {{ {compiled_handler} }});", @@ -1033,6 +1065,49 @@ impl IvyCodegen { code } + /// Build the listener-body prelude for a *root-level* listener whose + /// handler reads template reference variables: `ɵɵrestoreView(_r); const + /// = ɵɵreference(); …`. Returns `None` when this is not a + /// genuine root listener (we're inside a structural-directive child + /// template) or when the handler references no in-scope ref — in which + /// case the caller emits a plain listener. + /// + /// Embedded-view listeners (`@if` / `@for`, `scope_depth() > 0`) are + /// handled separately by [`generate_listener_preamble`]; this covers the + /// depth-0 case that path skips. + fn root_listener_ref_prelude(&mut self, compiled_handler: &str) -> Option { + if self.in_child_template || self.template_refs.is_empty() { + return None; + } + let used: Vec<(String, u32)> = self + .template_refs + .iter() + .filter(|(name, _)| identifier_used_in(compiled_handler, name)) + .map(|(name, slot)| (name.clone(), *slot)) + .collect(); + if used.is_empty() { + return None; + } + + // Capturing the view (`const _r = ɵɵgetCurrentView();`) is deferred to + // the root creation block in `generate_ivy`; flag that it's needed. + self.root_saved_view_needed = true; + self.ivy_imports + .insert("\u{0275}\u{0275}getCurrentView".to_string()); + self.ivy_imports + .insert("\u{0275}\u{0275}restoreView".to_string()); + self.ivy_imports + .insert("\u{0275}\u{0275}reference".to_string()); + + let mut prelude = String::from("\u{0275}\u{0275}restoreView(_r); "); + for (name, slot) in &used { + prelude.push_str(&format!( + "const {name} = \u{0275}\u{0275}reference({slot}); " + )); + } + Some(prelude) + } + /// Desugar a structural directive (*ngIf, *ngFor) to an ng-template wrapper. fn generate_structural_directive(&mut self, el: &ElementNode, dir_name: &str, dir_expr: &str) { let slot = self.slot_index; @@ -1131,7 +1206,10 @@ impl IvyCodegen { self.pipe_var_offset = 0; self.last_update_slot = None; + let parent_in_child = self.in_child_template; + self.in_child_template = true; self.generate_element(el); + self.in_child_template = parent_in_child; let decls = self.slot_index; @@ -3266,6 +3344,29 @@ fn collect_ctx_rewrites( ctx_inserts.push(id.span.start); } Expression::CallExpression(call) => { + // `$any(expr)` is Angular's compile-time cast: strip the call and + // keep only the inner expression (matching `ng build`, which emits + // no runtime `ctx.$any(...)` — there is no such member). Only treat + // it as a cast when `$any` is not shadowed by a template local. + if let Expression::Identifier(id) = &call.callee { + if id.name == "$any" && call.arguments.len() == 1 && !is_local("$any") { + if let Some(arg) = call.arguments.first() { + if !matches!(arg, Argument::SpreadElement(_)) { + let inner = arg.to_expression(); + remove_ranges.push((call.span.start, inner.span().start)); + remove_ranges.push((inner.span().end, call.span.end)); + collect_ctx_rewrites( + inner, + ctx_inserts, + remove_ranges, + is_member_property, + locals, + ); + return; + } + } + } + } collect_ctx_rewrites(&call.callee, ctx_inserts, remove_ranges, false, locals); for arg in &call.arguments { if let Argument::SpreadElement(spread) = arg { @@ -4387,6 +4488,105 @@ mod tests { assert!(output.static_fields[0].contains("listener")); } + #[test] + fn test_any_cast_is_stripped() { + // `$any(...)` is Angular's compile-time cast — it must be removed, not + // emitted as a runtime `ctx.$any(...)` member call. + assert_eq!(ctx_expr("$any(x).y"), "ctx.x.y"); + assert_eq!( + ctx_expr("onAny($any($event.target).value)"), + "ctx.onAny($event.target.value)" + ); + // Nested / standalone forms. + assert_eq!(ctx_expr("$any(value)"), "ctx.value"); + assert!(!ctx_expr("$any(a) + $any(b)").contains("$any")); + } + + #[test] + fn test_any_cast_not_stripped_when_shadowed_by_local() { + // If a template local named `$any` is in scope it is a real call, not + // the cast keyword — leave it intact (and unprefixed). + let mut locals = BTreeSet::new(); + locals.insert("$any".to_string()); + assert_eq!(ctx_expr_with_locals("$any(x)", &locals), "$any(ctx.x)"); + } + + #[test] + fn test_root_listener_reads_template_ref_via_reference() { + // `` at the root level: the + // generated listener must restore the view and declare + // `const box = ɵɵreference(slot)` so `box` resolves at runtime. + let comp = test_component(); + let nodes = vec![TemplateNode::Element(ElementNode { + tag: "input".to_string(), + attributes: vec![ + TemplateAttribute::Reference { + name: "box".to_string(), + export_as: None, + }, + TemplateAttribute::Event { + name: "input".to_string(), + handler: "onRef(box.value)".to_string(), + }, + ], + children: vec![], + is_void: true, + })]; + let output = generate_ivy(&comp, &nodes).expect("should generate"); + let dc = &output.static_fields[0]; + assert!( + dc.contains("const _r = \u{0275}\u{0275}getCurrentView();"), + "root creation block must capture the view: {dc}" + ); + assert!( + dc.contains("\u{0275}\u{0275}restoreView(_r);"), + "listener must restore the view before ɵɵreference: {dc}" + ); + assert!( + dc.contains("const box = \u{0275}\u{0275}reference("), + "listener must declare the ref via ɵɵreference(slot): {dc}" + ); + assert!( + !dc.contains("ctx.box"), + "the ref name must not be prefixed with ctx.: {dc}" + ); + for sym in ["getCurrentView", "restoreView", "reference"] { + assert!( + output.ivy_imports.contains(&format!("\u{0275}\u{0275}{sym}")), + "ivy_imports must include ɵɵ{sym}" + ); + } + } + + #[test] + fn test_root_listener_without_ref_stays_plain() { + // A root listener that reads no ref must NOT gain a restoreView prelude. + let comp = test_component(); + let nodes = vec![TemplateNode::Element(ElementNode { + tag: "input".to_string(), + attributes: vec![TemplateAttribute::Event { + name: "input".to_string(), + handler: "onAny($any($event.target).value)".to_string(), + }], + children: vec![], + is_void: true, + })]; + let output = generate_ivy(&comp, &nodes).expect("should generate"); + let dc = &output.static_fields[0]; + assert!( + !dc.contains("restoreView"), + "ref-free listener must not restore the view: {dc}" + ); + assert!( + !dc.contains("getCurrentView"), + "ref-free template must not capture the view: {dc}" + ); + assert!( + dc.contains("ctx.onAny($event.target.value)"), + "$any must be stripped in the listener body: {dc}" + ); + } + #[test] fn test_factory_code() { let comp = test_component(); From c56b132cf8295042b03e31eff0de4318179faad8 Mon Sep 17 00:00:00 2001 From: lukekania Date: Wed, 15 Jul 2026 16:50:40 +0200 Subject: [PATCH 18/20] style: cargo fmt --all Apply rustfmt across the workspace to satisfy `cargo fmt --all -- --check`. Reflows the new tree-shake/side-effects code plus pre-existing formatting drift on the milestone branch (serve_cmd, hmr, if_alias tests) that was failing CI. No behavioural changes. --- crates/bundler/src/concat.rs | 3 +- crates/bundler/src/rewrite.rs | 7 +- crates/bundler/src/shake.rs | 19 +++--- .../tests/barrel_treeshake_integration.rs | 7 +- crates/cli/src/main.rs | 10 ++- crates/cli/src/serve_cmd.rs | 66 +++++++++++++++---- crates/dev-server/src/lib.rs | 7 +- crates/npm-resolver/src/lib.rs | 10 ++- crates/npm-resolver/src/package_json.rs | 6 +- crates/template-compiler/src/codegen.rs | 26 ++++---- crates/template-compiler/src/hmr.rs | 35 +++++++--- crates/template-compiler/src/lib.rs | 7 +- .../tests/if_alias_binding_integration.rs | 20 +++--- 13 files changed, 149 insertions(+), 74 deletions(-) diff --git a/crates/bundler/src/concat.rs b/crates/bundler/src/concat.rs index 74b7f30..b40ce1b 100644 --- a/crates/bundler/src/concat.rs +++ b/crates/bundler/src/concat.rs @@ -216,8 +216,7 @@ pub fn bundle(input: &BundleInput) -> NgcResult { // `import().then(m => m.X)` — invisible to static analysis — so // keep all their exports. A shared vendor chunk's entry is just // the first package module; let `externally_used` drive shaking. - let seed_entry_exports = - matches!(chunk.kind, ChunkKind::Main | ChunkKind::Lazy); + let seed_entry_exports = matches!(chunk.kind, ChunkKind::Main | ChunkKind::Lazy); let shake = shake::analyze_unused_exports( &chunk.modules, &input.modules, diff --git a/crates/bundler/src/rewrite.rs b/crates/bundler/src/rewrite.rs index 15dde5c..06a27fd 100644 --- a/crates/bundler/src/rewrite.rs +++ b/crates/bundler/src/rewrite.rs @@ -167,7 +167,12 @@ fn collect_module_decl_edits( match module_decl { ModuleDeclaration::ImportDeclaration(import) => { let source = import.source.value.as_str(); - if is_local(source, local_prefixes, bundled_specifiers, external_specifiers) { + if is_local( + source, + local_prefixes, + bundled_specifiers, + external_specifiers, + ) { // Check if this import has a namespace mapping (npm module) if let Some(ns) = namespace_map.get(source) { // Replace import with namespace lookups diff --git a/crates/bundler/src/shake.rs b/crates/bundler/src/shake.rs index 8020a55..ae3f946 100644 --- a/crates/bundler/src/shake.rs +++ b/crates/bundler/src/shake.rs @@ -218,11 +218,7 @@ pub fn analyze_unused_exports( }; mark_reachable(&target, &mut reachable, &mut import_queue); for name in imported_names { - if used - .entry(target.clone()) - .or_default() - .insert(name.clone()) - { + if used.entry(target.clone()).or_default().insert(name.clone()) { use_queue.push((target.clone(), name.clone())); } } @@ -483,9 +479,8 @@ fn resolve_local_specifier( // never reaches `lodash-es/lodash.js` and the whole package is pinned. // Cross-chunk bare imports resolve to a path outside `module_paths` and fall // through to `None` (handled by the `externally_used` mechanism instead). - let is_bare = !specifier.starts_with('.') - && !specifier.starts_with('/') - && !specifier.starts_with('#'); + let is_bare = + !specifier.starts_with('.') && !specifier.starts_with('/') && !specifier.starts_with('#'); if is_bare { let ctx = subpath_ctx?; let resolved = ngc_npm_resolver::resolve::resolve_bare_specifier( @@ -809,7 +804,9 @@ mod tests { .expect("should analyze"); assert!( - !result.unused_exports.contains_key(&PathBuf::from("/root/main.ts")), + !result + .unused_exports + .contains_key(&PathBuf::from("/root/main.ts")), "entry module exports should never be marked unused" ); } @@ -843,7 +840,9 @@ mod tests { .expect("should analyze"); assert!( - !result.unused_exports.contains_key(&PathBuf::from("/root/side.ts")), + !result + .unused_exports + .contains_key(&PathBuf::from("/root/side.ts")), "side-effect module should not have unused exports listed" ); } diff --git a/crates/bundler/tests/barrel_treeshake_integration.rs b/crates/bundler/tests/barrel_treeshake_integration.rs index 87a912b..b1b7715 100644 --- a/crates/bundler/tests/barrel_treeshake_integration.rs +++ b/crates/bundler/tests/barrel_treeshake_integration.rs @@ -181,7 +181,12 @@ fn barrel_pkg_shakes_to_used_export_only() { let output = bundle(&input).expect("bundle succeeds"); // The lazy route chunk carries the used method and its helper... - let all_code: String = output.chunks.values().cloned().collect::>().join("\n"); + let all_code: String = output + .chunks + .values() + .cloned() + .collect::>() + .join("\n"); assert!( all_code.contains("function debounce"), "used export `debounce` must survive" diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 6c0771f..0731add 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -762,9 +762,7 @@ pub(crate) fn run_build_with_options( for resource in &comp.resource_files { // Canonicalize so the watcher's emitted paths (also // canonicalized at lookup) match across symlinks. - let key = resource - .canonicalize() - .unwrap_or_else(|_| resource.clone()); + let key = resource.canonicalize().unwrap_or_else(|_| resource.clone()); hmr_resource_to_component.insert(key, comp.id.clone()); } } @@ -855,9 +853,9 @@ pub(crate) fn run_build_with_options( .unwrap_or_default(); let is_external = |spec: &str| -> bool { external_specifiers.contains(spec) - || external_specifiers.iter().any(|ext| { - spec.starts_with(ext.as_str()) && spec[ext.len()..].starts_with('/') - }) + || external_specifiers + .iter() + .any(|ext| spec.starts_with(ext.as_str()) && spec[ext.len()..].starts_with('/')) }; let mut bare_specifiers: Vec = file_graph .npm_import_sites diff --git a/crates/cli/src/serve_cmd.rs b/crates/cli/src/serve_cmd.rs index ee4dc88..a614471 100644 --- a/crates/cli/src/serve_cmd.rs +++ b/crates/cli/src/serve_cmd.rs @@ -141,7 +141,8 @@ pub(crate) fn run_with_stop( // 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 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| { @@ -623,9 +624,12 @@ mod tests { #[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(); + 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( @@ -665,26 +669,57 @@ mod tests { #[test] fn classify_rebuild_routes_events() { use std::collections::{HashMap, HashSet}; - let styles: HashSet = [PathBuf::from("/proj/src/styles.css")].into_iter().collect(); + let styles: HashSet = [PathBuf::from("/proj/src/styles.css")] + .into_iter() + .collect(); let mut resources: HashMap = HashMap::new(); - resources.insert(PathBuf::from("/proj/src/app/app.component.html"), "id-app".to_string()); - resources.insert(PathBuf::from("/proj/src/app/app.component.css"), "id-app".to_string()); - resources.insert(PathBuf::from("/proj/src/app/foo.component.html"), "id-foo".to_string()); + resources.insert( + PathBuf::from("/proj/src/app/app.component.html"), + "id-app".to_string(), + ); + resources.insert( + PathBuf::from("/proj/src/app/app.component.css"), + "id-app".to_string(), + ); + resources.insert( + PathBuf::from("/proj/src/app/foo.component.html"), + "id-foo".to_string(), + ); // HMR off → always reload. assert!(matches!( - classify_rebuild(false, &[PathBuf::from("/proj/src/app/app.component.html")], &styles, &resources, 1).as_slice(), + classify_rebuild( + false, + &[PathBuf::from("/proj/src/app/app.component.html")], + &styles, + &resources, + 1 + ) + .as_slice(), [DevServerEvent::Reload] )); // Global stylesheet only → CssUpdate. assert!(matches!( - classify_rebuild(true, &[PathBuf::from("/proj/src/styles.css")], &styles, &resources, 5).as_slice(), + classify_rebuild( + true, + &[PathBuf::from("/proj/src/styles.css")], + &styles, + &resources, + 5 + ) + .as_slice(), [DevServerEvent::CssUpdate { timestamp: 5 }] )); // A component template → one ComponentUpdate for its id. - let evs = classify_rebuild(true, &[PathBuf::from("/proj/src/app/app.component.html")], &styles, &resources, 7); + let evs = classify_rebuild( + true, + &[PathBuf::from("/proj/src/app/app.component.html")], + &styles, + &resources, + 7, + ); match evs.as_slice() { [DevServerEvent::ComponentUpdate { id, timestamp: 7 }] => assert_eq!(id, "id-app"), other => panic!("expected one ComponentUpdate, got {other:?}"), @@ -718,7 +753,14 @@ mod tests { // A `.ts` (or any unknown) change → reload, even mixed with a resource. assert!(matches!( - classify_rebuild(true, &[PathBuf::from("/proj/src/app/app.component.ts")], &styles, &resources, 1).as_slice(), + classify_rebuild( + true, + &[PathBuf::from("/proj/src/app/app.component.ts")], + &styles, + &resources, + 1 + ) + .as_slice(), [DevServerEvent::Reload] )); assert!(matches!( diff --git a/crates/dev-server/src/lib.rs b/crates/dev-server/src/lib.rs index 197f321..c77edc4 100644 --- a/crates/dev-server/src/lib.rs +++ b/crates/dev-server/src/lib.rs @@ -1370,10 +1370,9 @@ mod tests { 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"); + 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")); } diff --git a/crates/npm-resolver/src/lib.rs b/crates/npm-resolver/src/lib.rs index 9eee6d1..4fd2e72 100644 --- a/crates/npm-resolver/src/lib.rs +++ b/crates/npm-resolver/src/lib.rs @@ -365,7 +365,11 @@ mod tests { // alpha + utils.mjs only — beta is external so its index.mjs must // not appear in modules and `beta` must not show up in resolved. - assert_eq!(result.modules.len(), 2, "beta's modules must not be pulled in"); + assert_eq!( + result.modules.len(), + 2, + "beta's modules must not be pulled in" + ); assert!( !result.resolved_specifiers.contains("beta"), "external 'beta' must not appear in resolved_specifiers" @@ -404,7 +408,9 @@ mod tests { // Only consumer is pulled in; the subpath import of jquery is // treated as external and never walked. assert_eq!(result.modules.len(), 1); - assert!(!result.resolved_specifiers.contains("jquery/dist/jquery.slim")); + assert!(!result + .resolved_specifiers + .contains("jquery/dist/jquery.slim")); } #[test] diff --git a/crates/npm-resolver/src/package_json.rs b/crates/npm-resolver/src/package_json.rs index 65e0a2c..1bc2725 100644 --- a/crates/npm-resolver/src/package_json.rs +++ b/crates/npm-resolver/src/package_json.rs @@ -105,7 +105,11 @@ fn glob_matches(glob: &str, path: &str) -> bool { let g = glob.strip_prefix("./").unwrap_or(glob); // A bare `**/x` or `*.ext` pattern should match at any directory depth. if let Some(suffix) = g.strip_prefix("**/") { - if path.rsplit('/').next().is_some_and(|base| simple_glob(suffix, base)) { + if path + .rsplit('/') + .next() + .is_some_and(|base| simple_glob(suffix, base)) + { return true; } return simple_glob(suffix, path); diff --git a/crates/template-compiler/src/codegen.rs b/crates/template-compiler/src/codegen.rs index f9c5e23..202947f 100644 --- a/crates/template-compiler/src/codegen.rs +++ b/crates/template-compiler/src/codegen.rs @@ -1424,12 +1424,7 @@ impl IvyCodegen { child.decls, child.vars )), } - else_if_slots.push(( - branch.condition.clone(), - fn_name.clone(), - ei_slot, - ei_alias, - )); + else_if_slots.push((branch.condition.clone(), fn_name.clone(), ei_slot, ei_alias)); self.child_templates.push(child); } @@ -3548,7 +3543,11 @@ fn build_test_chain( fn build_alias_value_chain(branches: &[(String, Option, u32)]) -> String { let mut expr = String::new(); for (compiled, alias, _slot) in branches { - let value = if alias.is_some() { compiled.as_str() } else { "null" }; + let value = if alias.is_some() { + compiled.as_str() + } else { + "null" + }; expr.push_str(&format!("{compiled} ? {value} : ")); } expr.push_str("null"); @@ -4552,7 +4551,9 @@ mod tests { ); for sym in ["getCurrentView", "restoreView", "reference"] { assert!( - output.ivy_imports.contains(&format!("\u{0275}\u{0275}{sym}")), + output + .ivy_imports + .contains(&format!("\u{0275}\u{0275}{sym}")), "ivy_imports must include ɵɵ{sym}" ); } @@ -5865,7 +5866,9 @@ mod tests { "must not fall back to ctx.. on the parent: {dc}" ); assert!( - dc.contains("\u{0275}\u{0275}conditional(ctx.item() ? 0 : -1, ctx.item() ? ctx.item() : null);"), + dc.contains( + "\u{0275}\u{0275}conditional(ctx.item() ? 0 : -1, ctx.item() ? ctx.item() : null);" + ), "ɵɵconditional must receive the truthy value as its second arg: {dc}" ); } @@ -5918,9 +5921,8 @@ mod tests { /// don't have the alias as their own function parameter. #[test] fn if_block_alias_reaches_nested_scopes_via_next_context() { - let output = compile_template( - "@if (state(); as s) { @switch (s.k) { @case ('a') { {{ s.v }} } } }", - ); + let output = + compile_template("@if (state(); as s) { @switch (s.k) { @case ('a') { {{ s.v }} } } }"); let dc = full_emit(&output); // The @switch case's template binds `s` from the @if's embedded view. assert!( diff --git a/crates/template-compiler/src/hmr.rs b/crates/template-compiler/src/hmr.rs index 64626f6..681ca73 100644 --- a/crates/template-compiler/src/hmr.rs +++ b/crates/template-compiler/src/hmr.rs @@ -32,7 +32,11 @@ use crate::codegen::IvyOutput; /// initializer, the dev-server registry key, and the running app's fetch URL. pub fn encode_uri_component(input: &str) -> String { fn is_unreserved(b: u8) -> bool { - b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'!' | b'~' | b'*' | b'\'' | b'(' | b')') + b.is_ascii_alphanumeric() + || matches!( + b, + b'-' | b'_' | b'.' | b'!' | b'~' | b'*' | b'\'' | b'(' | b')' + ) } let mut out = String::with_capacity(input.len()); for &b in input.as_bytes() { @@ -40,8 +44,16 @@ pub fn encode_uri_component(input: &str) -> String { out.push(b as char); } else { out.push('%'); - out.push(char::from_digit((b >> 4) as u32, 16).unwrap().to_ascii_uppercase()); - out.push(char::from_digit((b & 0xf) as u32, 16).unwrap().to_ascii_uppercase()); + out.push( + char::from_digit((b >> 4) as u32, 16) + .unwrap() + .to_ascii_uppercase(), + ); + out.push( + char::from_digit((b & 0xf) as u32, 16) + .unwrap() + .to_ascii_uppercase(), + ); } } out @@ -136,9 +148,7 @@ pub fn build_update_module_ts(class_name: &str, ivy: &IvyOutput, locals: &[Strin // `static ɵcmp = ɵɵdefineComponent({...})`; turn the class-field form into // an assignment statement on the class passed in as the first parameter. let def = ivy.static_fields.first().map(|s| s.as_str()).unwrap_or(""); - let def_expr = def - .strip_prefix("static \u{0275}cmp = ") - .unwrap_or(def); + let def_expr = def.strip_prefix("static \u{0275}cmp = ").unwrap_or(def); out.push_str(&format!(" {class_name}.\u{0275}cmp = {def_expr};\n")); out.push_str("}\n"); @@ -188,10 +198,16 @@ mod tests { #[test] fn initializer_embeds_id_locals_and_replace_metadata() { - let init = build_initializer("AppComponent", "the%2Fid%40AppComponent", &["RouterOutlet".into(), "MyPipe".into()]); + let init = build_initializer( + "AppComponent", + "the%2Fid%40AppComponent", + &["RouterOutlet".into(), "MyPipe".into()], + ); assert!(init.contains("var __ngId = 'the%2Fid%40AppComponent';")); assert!(init.contains("i0.\u{0275}\u{0275}replaceMetadata(AppComponent, m.default, [i0], [RouterOutlet, MyPipe], import.meta, __ngId)")); - assert!(init.contains("import('./@ng/component?c=' + __ngId + '&t=' + encodeURIComponent(t))")); + assert!( + init.contains("import('./@ng/component?c=' + __ngId + '&t=' + encodeURIComponent(t))") + ); assert!(init.contains("import.meta.hot.on('angular:component-update'")); } @@ -214,7 +230,8 @@ mod tests { let module = build_update_module_ts("App", &ivy, &["RouterOutlet".into()]); assert!(module.contains("export default function App_UpdateMetadata(App, \u{0275}\u{0275}namespaces, RouterOutlet)")); assert!(module.contains("const i0 = \u{0275}\u{0275}namespaces[0];")); - assert!(module.contains("var \u{0275}\u{0275}defineComponent = i0.\u{0275}\u{0275}defineComponent;")); + assert!(module + .contains("var \u{0275}\u{0275}defineComponent = i0.\u{0275}\u{0275}defineComponent;")); assert!(module.contains("function App_div_0_Template")); assert!(module.contains("App.\u{0275}cmp = \u{0275}\u{0275}defineComponent({")); // The update module must not reassign the factory (template/style-only). diff --git a/crates/template-compiler/src/lib.rs b/crates/template-compiler/src/lib.rs index 7c204df..20482ec 100644 --- a/crates/template-compiler/src/lib.rs +++ b/crates/template-compiler/src/lib.rs @@ -804,7 +804,8 @@ pub fn compile_component_with_options( let id = hmr::component_hmr_id(&style_ctx.project_root, file_path, &extracted.class_name); let locals = &extracted.imports_identifiers; let update_ts = hmr::build_update_module_ts(&extracted.class_name, &ivy_output, locals); - let update_module_source = ngc_ts_transform::transform_source(&update_ts, "ngc-hmr-update.ts")?; + let update_module_source = + ngc_ts_transform::transform_source(&update_ts, "ngc-hmr-update.ts")?; rewritten.push('\n'); rewritten.push_str(&hmr::build_initializer(&extracted.class_name, &id, locals)); @@ -931,7 +932,9 @@ export class XComponent {} // The rewritten module must add the `i0` namespace import and the // appended HMR initializer wired to `import.meta.hot`. - assert!(result.source.contains("import * as i0 from '@angular/core';")); + assert!(result + .source + .contains("import * as i0 from '@angular/core';")); assert!(result .source .contains("import.meta.hot.on('angular:component-update'")); diff --git a/crates/template-compiler/tests/if_alias_binding_integration.rs b/crates/template-compiler/tests/if_alias_binding_integration.rs index c158c3e..e2caac6 100644 --- a/crates/template-compiler/tests/if_alias_binding_integration.rs +++ b/crates/template-compiler/tests/if_alias_binding_integration.rs @@ -86,9 +86,8 @@ export class YComponent { #[test] fn if_alias_emits_runtime_correct_codegen() { - let compiled = - compile_component(DETAIL_FIXTURE, &PathBuf::from("detail.component.ts")) - .expect("component should compile"); + let compiled = compile_component(DETAIL_FIXTURE, &PathBuf::from("detail.component.ts")) + .expect("component should compile"); assert!( compiled.compiled, @@ -125,9 +124,8 @@ fn if_alias_emits_runtime_correct_codegen() { // expression is re-evaluated (same shape Angular's own compiler // emits) — `item()` appears on both sides of the ternary chain. assert!( - out.contains( - "\u{0275}\u{0275}conditional(ctx.item() ? " - ) && out.contains(", ctx.item() ? ctx.item() : null);"), + out.contains("\u{0275}\u{0275}conditional(ctx.item() ? ") + && out.contains(", ctx.item() ? ctx.item() : null);"), "ɵɵconditional must receive the alias value as its second arg:\n{out}" ); @@ -141,9 +139,8 @@ fn if_alias_emits_runtime_correct_codegen() { #[test] fn else_if_alias_binds_per_branch_independently() { - let compiled = - compile_component(ELSE_IF_FIXTURE, &PathBuf::from("x.component.ts")) - .expect("component should compile"); + let compiled = compile_component(ELSE_IF_FIXTURE, &PathBuf::from("x.component.ts")) + .expect("component should compile"); let out = &compiled.source; assert!( @@ -168,9 +165,8 @@ fn else_if_alias_binds_per_branch_independently() { #[test] fn nested_scope_reads_outer_if_alias_via_next_context() { - let compiled = - compile_component(NESTED_FIXTURE, &PathBuf::from("y.component.ts")) - .expect("component should compile"); + let compiled = compile_component(NESTED_FIXTURE, &PathBuf::from("y.component.ts")) + .expect("component should compile"); let out = &compiled.source; // The @switch case body is nested two levels deep (root → @if → @switch From 676cc7667b8501e7e6fe4a06aad0e906e67347cd Mon Sep 17 00:00:00 2001 From: lukekania Date: Wed, 15 Jul 2026 16:54:39 +0200 Subject: [PATCH 19/20] fix(template-compiler): satisfy clippy::question-mark in host_codegen Replace a nested `match ... { Some(d) => ..., None => return None }` with the `?` operator. Pre-existing lint that only surfaced now that `cargo fmt --check` no longer fails the CI lint job first. --- crates/template-compiler/src/host_codegen.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/template-compiler/src/host_codegen.rs b/crates/template-compiler/src/host_codegen.rs index 8fd4d34..2e9f1b5 100644 --- a/crates/template-compiler/src/host_codegen.rs +++ b/crates/template-compiler/src/host_codegen.rs @@ -349,13 +349,13 @@ pub fn transform_host_directives_array(source_text: &str) -> Option { } let arr = match parsed.program.body.first() { - Some(Statement::VariableDeclaration(decl)) => match decl.declarations.first() { - Some(d) => match &d.init { + Some(Statement::VariableDeclaration(decl)) => { + let d = decl.declarations.first()?; + match &d.init { Some(Expression::ArrayExpression(a)) => a, _ => return None, - }, - None => return None, - }, + } + } _ => return None, }; From a8482efc25953da485e1df4d2ee0d174c476f3c4 Mon Sep 17 00:00:00 2001 From: lukekania Date: Wed, 15 Jul 2026 17:09:55 +0200 Subject: [PATCH 20/20] chore: bump version to 0.11.0 Finalize the v0.11.0 builder-parity milestone. This release closes the parity:important set: dev-server option parity (SSL, headers, allowedHosts, HMR), bundler parity (externalDependencies, barrel-package vendor tree-shaking), i18n parity (localize subsets, per-locale ngsw.json), `@if (expr; as alias)` runtime binding, and template-listener fixes (`$any()` strip, template refs). --- Cargo.lock | 20 ++++++++++---------- Cargo.toml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6bfc489..0092839 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -874,7 +874,7 @@ dependencies = [ [[package]] name = "ngc-bundler" -version = "0.10.19" +version = "0.11.0" dependencies = [ "dashmap", "ngc-diagnostics", @@ -899,7 +899,7 @@ dependencies = [ [[package]] name = "ngc-dev-server" -version = "0.10.19" +version = "0.11.0" dependencies = [ "ngc-diagnostics", "rcgen", @@ -912,7 +912,7 @@ dependencies = [ [[package]] name = "ngc-diagnostics" -version = "0.10.19" +version = "0.11.0" dependencies = [ "serde_json", "thiserror", @@ -920,7 +920,7 @@ dependencies = [ [[package]] name = "ngc-linker" -version = "0.10.19" +version = "0.11.0" dependencies = [ "dashmap", "insta", @@ -938,7 +938,7 @@ dependencies = [ [[package]] name = "ngc-npm-resolver" -version = "0.10.19" +version = "0.11.0" dependencies = [ "dashmap", "ngc-diagnostics", @@ -953,7 +953,7 @@ dependencies = [ [[package]] name = "ngc-project-resolver" -version = "0.10.19" +version = "0.11.0" dependencies = [ "dashmap", "glob", @@ -969,7 +969,7 @@ dependencies = [ [[package]] name = "ngc-rs" -version = "0.10.19" +version = "0.11.0" dependencies = [ "base64 0.22.1", "clap", @@ -1003,7 +1003,7 @@ dependencies = [ [[package]] name = "ngc-template-compiler" -version = "0.10.19" +version = "0.11.0" dependencies = [ "insta", "ngc-diagnostics", @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "ngc-ts-transform" -version = "0.10.19" +version = "0.11.0" dependencies = [ "ngc-diagnostics", "oxc_allocator", @@ -1044,7 +1044,7 @@ dependencies = [ [[package]] name = "ngc-watch" -version = "0.10.19" +version = "0.11.0" dependencies = [ "ngc-diagnostics", "notify", diff --git a/Cargo.toml b/Cargo.toml index 36ddd79..71b5dca 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.19" +version = "0.11.0" edition = "2021" license = "MIT OR Apache-2.0" authors = ["lukekania"]