diff --git a/.bazelrc b/.bazelrc index 92e60820829a..a02316a9aa50 100644 --- a/.bazelrc +++ b/.bazelrc @@ -83,14 +83,14 @@ test:saucelabs --define=KARMA_WEB_TEST_MODE=SL_REQUIRED # Releases should always be stamped with version control info # This command assumes node on the path and is a workaround for # https://github.com/bazelbuild/bazel/issues/4802 -build:release --workspace_status_command="pnpm -s ng-dev release build-env-stamp --mode=release" +build:release --workspace_status_command="pnpm --silent ng-dev release build-env-stamp --mode=release" build:release --stamp -build:snapshot --workspace_status_command="pnpm -s ng-dev release build-env-stamp --mode=snapshot" +build:snapshot --workspace_status_command="pnpm --silent ng-dev release build-env-stamp --mode=snapshot" build:snapshot --stamp build:snapshot --//:enable_snapshot_repo_deps -build:e2e --workspace_status_command="pnpm -s ng-dev release build-env-stamp --mode=release" +build:e2e --workspace_status_command="pnpm --silent ng-dev release build-env-stamp --mode=release" build:e2e --stamp test:e2e --test_timeout=3600 --experimental_ui_max_stdouterr_bytes=2097152 diff --git a/.bazelversion b/.bazelversion index df5119ec64e6..3b6825376add 100644 --- a/.bazelversion +++ b/.bazelversion @@ -1 +1 @@ -8.7.0 +8.8.0 diff --git a/.github/codeql/config.yml b/.github/codeql/config.yml deleted file mode 100644 index ad81a268eda4..000000000000 --- a/.github/codeql/config.yml +++ /dev/null @@ -1,8 +0,0 @@ -name: 'Angular CLI CodeQL config' - -query-filters: - # TODO(josephperrott): reevaluate if these can be reenabled. - - exclude: - id: js/bad-code-sanitization - - exclude: - id: js/regex-injection diff --git a/.github/workflows/assistant-to-the-branch-manager.yml b/.github/workflows/assistant-to-the-branch-manager.yml index 94fdfb8df840..d96db1e3d645 100644 --- a/.github/workflows/assistant-to-the-branch-manager.yml +++ b/.github/workflows/assistant-to-the-branch-manager.yml @@ -2,6 +2,7 @@ name: DevInfra on: push: + # zizmor: ignore[dangerous-triggers] - {Trigger is safe as workflow does not checkout untrusted code} pull_request_target: types: [opened, synchronize, reopened, ready_for_review, labeled] @@ -17,6 +18,6 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: angular/dev-infra/github-actions/branch-manager@ba726e7bca0b08b125ccc6f93c233749e1213c17 + - uses: angular/dev-infra/github-actions/branch-manager@bf1d18837b5aaed70cf858ecd177583bf320be50 # main with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57b31cb9c2f1..5bc5ede289ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Generate JSON schema types @@ -44,11 +44,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/configure-remote@bf1d18837b5aaed70cf858ecd177583bf320be50 # main with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Install node modules @@ -61,11 +61,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/configure-remote@bf1d18837b5aaed70cf858ecd177583bf320be50 # main with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Install node modules @@ -84,13 +84,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/configure-remote@bf1d18837b5aaed70cf858ecd177583bf320be50 # main with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Run CLI E2E tests @@ -100,11 +100,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/configure-remote@bf1d18837b5aaed70cf858ecd177583bf320be50 # main with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Install node modules @@ -137,7 +137,7 @@ jobs: runs-on: windows-2025 steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Download built Windows E2E tests @@ -164,13 +164,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/configure-remote@bf1d18837b5aaed70cf858ecd177583bf320be50 # main with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Run CLI E2E tests @@ -188,13 +188,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/configure-remote@bf1d18837b5aaed70cf858ecd177583bf320be50 # main with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Run CLI E2E tests @@ -208,34 +208,35 @@ jobs: SAUCE_TUNNEL_IDENTIFIER: angular-cli-${{ github.workflow }}-${{ github.run_number }} steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/configure-remote@bf1d18837b5aaed70cf858ecd177583bf320be50 # main with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Start Sauce Connect + uses: saucelabs/sauce-connect-action@cb88b508c6f9ff4d84490093733315dbd55de022 # v3 + timeout-minutes: 10 + with: + username: ${{ vars.SAUCE_USERNAME }} + accessKey: ${{ secrets.SAUCE_ACCESS_KEY }} + region: us-west-1 + tunnelName: ${{ env.SAUCE_TUNNEL_IDENTIFIER }} + proxyLocalhost: allow + # By default we disable SSL bumping for all requests. This is because SSL bumping is not needed + # for our test setup and in order to perform the SSL bumping, Saucelabs intercepts all HTTP + # requests in the tunnel VM and modifies them. This can cause flakiness as it makes all requests + # dependent on the SSL bumping middleware. + # See: https://wiki.saucelabs.com/display/DOCS/Troubleshooting+Sauce+Connect#TroubleshootingSauceConnect-DisablingSSLBumping + tlsPassthroughDomains: all - name: Run E2E Browser tests env: SAUCE_USERNAME: ${{ vars.SAUCE_USERNAME }} SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} - SAUCE_LOG_FILE: /tmp/angular/sauce-connect.log - SAUCE_READY_FILE: /tmp/angular/sauce-connect-ready-file.lock - SAUCE_PID_FILE: /tmp/angular/sauce-connect-pid-file.lock - SAUCE_TUNNEL_IDENTIFIER: 'angular-${{ github.run_number }}' - SAUCE_READY_FILE_TIMEOUT: 120 - run: | - ./scripts/saucelabs/start-tunnel.sh & - ./scripts/saucelabs/wait-for-tunnel.sh - pnpm bazel test --config=saucelabs //tests:e2e.saucelabs - ./scripts/saucelabs/stop-tunnel.sh - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - if: ${{ failure() }} - with: - name: sauce-connect-log - path: ${{ env.SAUCE_CONNECT_DIR_IN_HOST }}/sauce-connect.log + run: pnpm bazel test --config=saucelabs //tests:e2e.saucelabs publish-snapshots: needs: build @@ -244,11 +245,11 @@ jobs: CIRCLE_BRANCH: ${{ github.ref_name }} steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - run: pnpm admin snapshots --verbose env: SNAPSHOT_BUILDS_GITHUB_TOKEN: ${{ secrets.SNAPSHOT_BUILDS_GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index f95ab2f4ac05..000000000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: 'CodeQL' - -on: - push: - branches: ['main', '*.*.x'] - schedule: - - cron: '39 9 * * 1' - -permissions: {} - -jobs: - analyze: - name: Analyze - runs-on: 'ubuntu-latest' - permissions: - security-events: write - packages: read - strategy: - fail-fast: false - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Initialize CodeQL - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 - with: - languages: javascript-typescript - build-mode: none - config-file: .github/codeql/config.yml - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 - with: - category: '/language:javascript-typescript' diff --git a/.github/workflows/dev-infra.yml b/.github/workflows/dev-infra.yml index e0efdf2b94e4..0f4aea85fdb0 100644 --- a/.github/workflows/dev-infra.yml +++ b/.github/workflows/dev-infra.yml @@ -1,6 +1,7 @@ name: DevInfra on: + # zizmor: ignore[dangerous-triggers] - {Trigger is safe as workflow does not checkout untrusted code} pull_request_target: types: [opened, synchronize, reopened] issues: @@ -15,21 +16,21 @@ jobs: if: github.event_name == 'pull_request_target' runs-on: ubuntu-latest steps: - - uses: angular/dev-infra/github-actions/labeling/pull-request@649c3afeaa46674507b9625537e49de54a695e2b + - uses: angular/dev-infra/github-actions/labeling/pull-request@bf1d18837b5aaed70cf858ecd177583bf320be50 # main with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} post_approval_changes: if: github.event_name == 'pull_request_target' runs-on: ubuntu-latest steps: - - uses: angular/dev-infra/github-actions/post-approval-changes@649c3afeaa46674507b9625537e49de54a695e2b + - uses: angular/dev-infra/github-actions/post-approval-changes@bf1d18837b5aaed70cf858ecd177583bf320be50 # main with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} issue_labels: if: github.event_name == 'issues' runs-on: ubuntu-latest steps: - - uses: angular/dev-infra/github-actions/labeling/issue@649c3afeaa46674507b9625537e49de54a695e2b + - uses: angular/dev-infra/github-actions/labeling/issue@bf1d18837b5aaed70cf858ecd177583bf320be50 # main with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} google-generative-ai-key: ${{ secrets.GOOGLE_GENERATIVE_AI_KEY }} diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index dac67ebcbe7f..a3170cf0add5 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -9,7 +9,6 @@ on: permissions: contents: 'read' - id-token: 'write' defaults: run: @@ -23,24 +22,27 @@ jobs: workflows: ${{ steps.workflows.outputs.workflows }} steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile - id: workflows - run: echo "workflows=$(pnpm -s ng-dev perf workflows --list)" >> "$GITHUB_OUTPUT" + run: echo "workflows=$(pnpm --silent ng-dev perf workflows --list)" >> "$GITHUB_OUTPUT" workflow: timeout-minutes: 30 runs-on: ubuntu-latest needs: list + permissions: + contents: 'read' + id-token: 'write' strategy: matrix: workflow: ${{ fromJSON(needs.list.outputs.workflows) }} steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile # We utilize the google-github-actions/auth action to allow us to get an active credential using workflow @@ -52,4 +54,7 @@ jobs: project_id: 'internal-200822' workload_identity_provider: 'projects/823469418460/locations/global/workloadIdentityPools/measurables-tracking/providers/angular' service_account: 'measures-uploader@internal-200822.iam.gserviceaccount.com' - - run: pnpm ng-dev perf workflows --name ${{ matrix.workflow }} --commit-sha ${{github.sha}} + - run: pnpm ng-dev perf workflows --name "$WORKFLOW" --commit-sha "$COMMIT_SHA" + env: + WORKFLOW: ${{ matrix.workflow }} + COMMIT_SHA: ${{ github.sha }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0f5a37f5751a..5d86977c77a4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: filters: | @@ -34,9 +34,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup ESLint Caching uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -66,17 +66,17 @@ jobs: # it has been merged. run: pnpm ng-dev format changed --check ${{ github.event.pull_request.base.sha }} - name: Check Package Licenses - uses: angular/dev-infra/github-actions/linting/licenses@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/linting/licenses@bf1d18837b5aaed70cf858ecd177583bf320be50 # main build: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/configure-remote@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Build release targets @@ -93,11 +93,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/configure-remote@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Run module and package tests @@ -114,13 +114,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/configure-remote@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Run CLI E2E tests run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }} @@ -128,11 +128,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/configure-remote@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Build E2E tests for Windows on Linux @@ -156,7 +156,7 @@ jobs: runs-on: windows-2025 steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Download built Windows E2E tests @@ -183,13 +183,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/configure-remote@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Run CLI E2E tests run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=3 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }} @@ -205,12 +205,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/setup@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 + uses: angular/dev-infra/github-actions/bazel/configure-remote@bf1d18837b5aaed70cf858ecd177583bf320be50 # main - name: Run CLI E2E tests run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.snapshots.${{ matrix.subset }}_node${{ matrix.node }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml deleted file mode 100644 index 2e9f8b03919a..000000000000 --- a/.github/workflows/scorecard.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: OpenSSF Scorecard -on: - branch_protection_rule: - schedule: - - cron: '0 2 * * 0' - push: - branches: [main] - workflow_dispatch: - -# Declare default permissions as read only. -permissions: - contents: read - -jobs: - analysis: - name: Scorecards analysis - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - # Needed to upload the results to code-scanning dashboard. - security-events: write - # Needed to publish results - id-token: write - - steps: - - name: 'Checkout code' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: 'Run analysis' - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 - with: - results_file: results.sarif - results_format: sarif - publish_results: true - - # Upload the results as artifacts. - - name: 'Upload artifact' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: SARIF file - path: results.sarif - retention-days: 5 - - # Upload the results to GitHub's code scanning dashboard. - - name: 'Upload to code-scanning' - uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 - with: - sarif_file: results.sarif diff --git a/.husky/commit-msg b/.husky/commit-msg index 0c6213fc6bb7..de361903a541 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1 +1 @@ -pnpm -s ng-dev commit-message pre-commit-validate --file $1; +pnpm --silent ng-dev commit-message pre-commit-validate --file $1; diff --git a/.husky/pre-commit b/.husky/pre-commit index bbcdc40e0112..7f9d46b47835 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -pnpm -s ng-dev format staged; +pnpm --silent ng-dev format staged; diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg index 2333b7b798c0..f4598b6d1b6e 100755 --- a/.husky/prepare-commit-msg +++ b/.husky/prepare-commit-msg @@ -1 +1 @@ -pnpm -s ng-dev commit-message restore-commit-message-draft $1 $2; +pnpm --silent ng-dev commit-message restore-commit-message-draft $1 $2; diff --git a/.nvmrc b/.nvmrc index f9e7451e7715..df6ae3370f9f 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.23.1 +24.21.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8867117ff498..a67b3ed682d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,172 +1,784 @@ - + -# 22.0.8 (2026-07-23) +# 22.2.0-rc.0 (2026-09-16) ### @angular/cli -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | -| [41c356256](https://github.com/angular/angular-cli/commit/41c356256525469a41ea72896b1ddff91a4e6e55) | fix | batch Prettier invocations during migration formatting | -| [bc87c85c3](https://github.com/angular/angular-cli/commit/bc87c85c3c660e969cf77a493dc4ee58fd831214) | fix | update MCP devserver tool names to conform to spec regex | +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [6e7f809a7d](https://github.com/angular/angular-cli/commit/6e7f809a7d66e4b8d260a1c00297812412a926f1) | perf | skip eager yargs help message formatting during command execution | ### @angular/build -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | -| [5724bf632](https://github.com/angular/angular-cli/commit/5724bf632eba59eccdd941beae8aafad35ce003e) | fix | canonicalize drive letter casing for workspace root on Windows | -| [b8bdbaa1e](https://github.com/angular/angular-cli/commit/b8bdbaa1ea1c58f061580bd58c015078f57c1530) | fix | ensure import map integrity keys are valid URL-like specifiers | -| [212a7bb21](https://github.com/angular/angular-cli/commit/212a7bb21eca131f32fd7b4e7b5a9ab87f2bfded) | fix | favor istanbul coverage provider when browser testing is enabled | -| [bc760b005](https://github.com/angular/angular-cli/commit/bc760b005e0dae10a2dfd31a7f0f772482305398) | fix | preserve custom config options in runnerConfig for vitest | -| [19cda7415](https://github.com/angular/angular-cli/commit/19cda7415693f36e911a736a32576110905b6600) | fix | remap metafile paths when workspace root is a symlink or junction | -| [fdcc98c79](https://github.com/angular/angular-cli/commit/fdcc98c7912f45431268fdf66c8b132bd119bc27) | perf | skip semantic affected-file walk when type checking is disabled | +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [978351af71](https://github.com/angular/angular-cli/commit/978351af71889d4786a18fec9e193eb5b16af7dd) | fix | include inline component stylesheets in referenced watch files | +| [72dc9677cf](https://github.com/angular/angular-cli/commit/72dc9677cf743243d6cb22d2b81782022f71356e) | fix | pass load cache to compiler plugin and escape extension regex in polyfills | +| [31c045639e](https://github.com/angular/angular-cli/commit/31c045639e8bfd114cbd552d83cc70a968fe7f81) | fix | prevent stale bundler caching and correctly resolve load cache | +| [d97c8857c1](https://github.com/angular/angular-cli/commit/d97c8857c18da02fcc6c69a0ea652cc706c5c9b6) | perf | key Sass package resolutions without containing URL qualification | + + + + + +# 22.2.0-next.7 (2026-09-10) + +## Deprecations + +### @angular/build + +- The `splitting` option in the unit-test builder is deprecated as it is no longer needed with Vitest 5. + + Disabling code splitting is no longer necessary with Vitest 5, as the issue where shared-module exports were uninitialized in class-field initializers under JSDOM has been resolved. + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | +| [fe2f180fd0](https://github.com/angular/angular-cli/commit/fe2f180fd0c2fa02706353ca6a034d235feb6c59) | fix | add automatic corruption recovery in SQLite cache store | +| [675f1f5274](https://github.com/angular/angular-cli/commit/675f1f5274ff853f153832f7adc69273fa63f0cd) | fix | avoid pure comment pattern in oxc transform | +| [d396324b4e](https://github.com/angular/angular-cli/commit/d396324b4eb3b16640526a64365d953642bc7ca1) | fix | default preloadInitial to false when serviceWorker is enabled | +| [8504cfffcf](https://github.com/angular/angular-cli/commit/8504cfffcfb943fcd8fda62507e4b236e4bf429a) | fix | disable root config extension for inline test project | +| [5d21812aae](https://github.com/angular/angular-cli/commit/5d21812aaedc57cbd9378a99d9747f80c413cdc3) | fix | disable strictPort when port 0 is used in dev-server | +| [54d712f579](https://github.com/angular/angular-cli/commit/54d712f57904d2f612eec27a0507ea298b40915e) | fix | ensure parent directory exists in SQLite cache store | +| [b423bc39cd](https://github.com/angular/angular-cli/commit/b423bc39cd59c73ae79402c6cccdc0ecf1d7aaa1) | fix | use chokidar watcher when followSymlinks is enabled | +| [5a0d226b7c](https://github.com/angular/angular-cli/commit/5a0d226b7ccb945edd234fa0c839994f4876e2ee) | perf | avoid full JSON parsing when updating sourcemap ignore list | +| [482f6d8aab](https://github.com/angular/angular-cli/commit/482f6d8aab289fbd6dc5f9aecdcf7b8e1b6cac4f) | perf | decouple diagnostic type checking from build start | +| [f1afa603b4](https://github.com/angular/angular-cli/commit/f1afa603b48566e45dfb8d95930375a71f0bfa0b) | perf | read rendered module length once per module in chunk optimizer ([#34045](https://github.com/angular/angular-cli/pull/34045)) | +| [0ba4d8e29c](https://github.com/angular/angular-cli/commit/0ba4d8e29c0fb9abe6b07432edfab9e39066ca29) | perf | reduce watcher debounce latency for faster incremental rebuilds | +| [4d11a1334f](https://github.com/angular/angular-cli/commit/4d11a1334ff5fabfe5338cc3de5ce4f45f6afa5f) | perf | selectively compress large entries in SQLite cache store | +| [0a137f98d0](https://github.com/angular/angular-cli/commit/0a137f98d035f3dbd9a5daf1b6999246f73610c8) | perf | share sass directory and resolution caches across stylesheets | +| [d11a663e77](https://github.com/angular/angular-cli/commit/d11a663e77dad3dc9dbb6336e782f23b8a6f595c) | perf | verify cache metadata before reading file and eliminate sqlite read-locks | +| [66bd6bd1ea](https://github.com/angular/angular-cli/commit/66bd6bd1eab41484ed5db38f5545e80de4090b34) | refactor | deprecate splitting option in unit-test builder | + +### @angular/ssr + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [23e3d44a7f](https://github.com/angular/angular-cli/commit/23e3d44a7f051cd3bb67700b8d8407f73b7aa7f3) | perf | pre-compile critical CSS plans at build time | - + -# 22.1.0-next.4 (2026-07-15) +# 21.2.24 (2026-09-10) + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------- | +| [dbaadc2f6e](https://github.com/angular/angular-cli/commit/dbaadc2f6e73b611e292b3337066fd5b3b631397) | fix | update undici to 7.29.1 | + + + + + +# 20.3.37 (2026-09-10) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------- | +| [c37642011e](https://github.com/angular/angular-cli/commit/c37642011e6d8f7656a2bbc61feb79cae7d353c4) | fix | update undici to 7.29.1 | + + + + + +# 22.1.8 (2026-09-10) + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------- | +| [a6e30fa378](https://github.com/angular/angular-cli/commit/a6e30fa378ef5f03b26cd2fcafff6bff2c47da80) | fix | disable strictPort when port 0 is used in dev-server | +| [35a5d4aa49](https://github.com/angular/angular-cli/commit/35a5d4aa49011f83f58cdd30c3ae24a55bf0651c) | perf | avoid full JSON parsing when updating sourcemap ignore list | +| [48cc43cbf2](https://github.com/angular/angular-cli/commit/48cc43cbf204c887b344b773045b5460265eaa14) | perf | read rendered module length once per module in chunk optimizer ([#34045](https://github.com/angular/angular-cli/pull/34045)) | + + + + + +# 22.2.0-next.6 (2026-09-02) ### @angular/cli +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [b37c93329](https://github.com/angular/angular-cli/commit/b37c933299dac37e394d9f4fe0d019052db427a9) | fix | isolate temporary package installations from parent Yarn workspace | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [4b8c62a8f](https://github.com/angular/angular-cli/commit/4b8c62a8f1a1d312ef2e1e0df6b7642bf1db5c5f) | fix | add splitting option to unit-test builder | +| [51565d4bc](https://github.com/angular/angular-cli/commit/51565d4bcbd4667c23c607d878f08301afc4c5e3) | fix | enable code splitting for vitest browser tests | +| [5b850fdfc](https://github.com/angular/angular-cli/commit/5b850fdfc7534d33db7ab4a7f1e32e143462eec7) | fix | fail build and exclude routes when prerendering fails | +| [dfa7f03a9](https://github.com/angular/angular-cli/commit/dfa7f03a93b5b24b949aea354bc40f0a91e9582d) | fix | keep dev-server Vite cache worktree-local | +| [135662d9a](https://github.com/angular/angular-cli/commit/135662d9a1de85b42292b26b547160eaca71e5da) | fix | preserve binary values in the SQLite cache store | +| [701bde481](https://github.com/angular/angular-cli/commit/701bde4818439de0e49c99d57dc749b0dd22c4dd) | fix | preserve duplicate bundle filenames across output file types | +| [6ee559ca9](https://github.com/angular/angular-cli/commit/6ee559ca9d209fb4c53de2aaf9918d10e5f062a1) | perf | bypass worker dispatch for files without transform candidates | +| [2b85529e8](https://github.com/angular/angular-cli/commit/2b85529e8290c8c916539b5a0336b4efdb68578c) | perf | use iterative post-order AST traversal for OXC transforms | +| [ecbcd87b8](https://github.com/angular/angular-cli/commit/ecbcd87b8857225e4df3df7896b3236d63553f23) | perf | use native sass-embedded compiler daemon | +| [ce83ac0e8](https://github.com/angular/angular-cli/commit/ce83ac0e82981efd9408e07a35315e3cef9e1a36) | perf | use size-weighted task heuristics in i18n inliner | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [4fa81d403](https://github.com/angular/angular-cli/commit/4fa81d403c93a7480301e58630203cb028aebc69) | fix | decode pathname when constructing server asset path | +| [bb72145f9](https://github.com/angular/angular-cli/commit/bb72145f9ab45aee29f523236b3a25cd0813a841) | fix | ensure public directory containment in CommonEngine | + + + + + +# 22.1.7 (2026-09-02) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [e9db85c0f](https://github.com/angular/angular-cli/commit/e9db85c0f5ef0aa0c67d2b8d90b872307af39822) | fix | isolate temporary package installations from parent Yarn workspace | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [1c9b1af61](https://github.com/angular/angular-cli/commit/1c9b1af61115764e0be55f5a1ed9a9967c191a74) | fix | add splitting option to unit-test builder | +| [4b861f458](https://github.com/angular/angular-cli/commit/4b861f458c8c789a5276f7b591bebd28e2080444) | fix | enable code splitting for vitest browser tests | +| [f1fd82381](https://github.com/angular/angular-cli/commit/f1fd823814b5e3620838beb423d5f33600c2e546) | fix | fail build and exclude routes when prerendering fails | +| [a84906fe3](https://github.com/angular/angular-cli/commit/a84906fe393e8c2dee978c02631af20e93627df1) | fix | keep dev-server Vite cache worktree-local | +| [fcf883ef1](https://github.com/angular/angular-cli/commit/fcf883ef166dc2e34d86007c0fcdfad1d0434c86) | fix | preserve binary values in the SQLite cache store | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [baf1ca189](https://github.com/angular/angular-cli/commit/baf1ca189dcf1ab0e55ac853c80f892dbd1864e4) | fix | decode pathname when constructing server asset path | +| [70748ca8e](https://github.com/angular/angular-cli/commit/70748ca8e76fe4b42798719410336119d943e755) | fix | ensure public directory containment in CommonEngine | + + + + + +# 21.2.23 (2026-09-02) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------- | +| [d3aaa48ef](https://github.com/angular/angular-cli/commit/d3aaa48ef35b80c3297ee3050cd239f84d051899) | fix | update less to 4.9.0 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [645e41a47](https://github.com/angular/angular-cli/commit/645e41a47d21b7651837a4250de99af0509626e2) | fix | ensure public directory containment in CommonEngine | + + + + + +# 20.3.36 (2026-09-02) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------- | +| [b4059cdc7](https://github.com/angular/angular-cli/commit/b4059cdc7b7a65e68abfcf9fb53a6304790b1796) | fix | update less to 4.9.0 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [c3e5982e4](https://github.com/angular/angular-cli/commit/c3e5982e49705f0f6a913cb94622b4c555d2d914) | fix | ensure public directory containment in CommonEngine | + + + + + +# 22.2.0-next.5 (2026-08-26) + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------- | +| [4d0be683b7](https://github.com/angular/angular-cli/commit/4d0be683b780a9a1629dc8f9cddad49153bddabf) | fix | ignore side-effect annotations in unit test builds | +| [e4111ae818](https://github.com/angular/angular-cli/commit/e4111ae8180f3cd5800f248c23010844aa1730ad) | fix | prevent memory leaks in parallel compilation and caches | +| [62999cabf4](https://github.com/angular/angular-cli/commit/62999cabf4d65cf1060598150a77494e147fcc2a) | perf | batch prerender routes and share in-memory server bundles | +| [0589edd9cd](https://github.com/angular/angular-cli/commit/0589edd9cdc493ecf34a160cc7b8491618b59f72) | perf | consolidate component stylesheet bundling with shared load result cache | +| [a877aa7ebd](https://github.com/angular/angular-cli/commit/a877aa7ebdbf919d6bc17e6ec0920ca702db0db4) | perf | implement SharedArrayBuffer translation dictionaries for zero-copy worker access | +| [f1cc959439](https://github.com/angular/angular-cli/commit/f1cc959439d8ca11a45d693520c34dd94e0b6548) | perf | implement sliding-window batching and worker translation eviction | +| [f4dad31cce](https://github.com/angular/angular-cli/commit/f4dad31cce41bc3e8ed8767256d9e50a712c3a72) | perf | unify Oxc linking and optimization AST traversal passes | + +### @angular/ssr + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [f8576e3c8b](https://github.com/angular/angular-cli/commit/f8576e3c8bfe3490b1b0b85832b489593a4e43ac) | fix | abort web request signal when node request is aborted | + + + + + +# 22.1.6 (2026-08-26) + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [3f132eaa97](https://github.com/angular/angular-cli/commit/3f132eaa97b95e16c58b8095c2566e3c78190446) | fix | ignore side-effect annotations in unit test builds | +| [5b1028936f](https://github.com/angular/angular-cli/commit/5b1028936f6e9582917f95ac37942dca0ee2c659) | perf | batch prerender routes and share in-memory server bundles | + +### @angular/ssr + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [76818cb811](https://github.com/angular/angular-cli/commit/76818cb811a8fe669575f988fec2e4528dca8c73) | fix | abort web request signal when node request is aborted | + + + + + +# 21.2.22 (2026-08-26) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- | +| [b0047b04f5](https://github.com/angular/angular-cli/commit/b0047b04f56f5cb3fb8e4465ba0fad777497144a) | fix | update webpack-dev-server to 5.2.6 | + + + + + +# 20.3.35 (2026-08-26) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- | +| [94b620acd7](https://github.com/angular/angular-cli/commit/94b620acd76e39c7d5cc8527dfc6fe9096855cbd) | fix | update webpack-dev-server to 5.2.6 | + + + + + +# 22.2.0-next.4 (2026-08-19) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [34e1e0bb5](https://github.com/angular/angular-cli/commit/34e1e0bb5d5ccf374f51a1b241158f6f2ea0dd42) | fix | enforce MCP roots in get_best_practices tool | +| [2b060630c](https://github.com/angular/angular-cli/commit/2b060630c66ce688b7ecbe51d282955869e5d4b0) | fix | handle errors from isAllowedWorkspacePath in best-practices tool | +| [24fd8fce2](https://github.com/angular/angular-cli/commit/24fd8fce2a3ff035acdc5f3e036eb4ba21f71a76) | fix | throw on out-of-roots workspace in best-practices tool | + +### @schematics/angular + | Commit | Type | Description | | --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | -| [3789ceee1](https://github.com/angular/angular-cli/commit/3789ceee18b4528c2acb7989b5d41c32d386a8a8) | fix | copy packageManager field and yarn config for temp installs | -| [b0b3a47ae](https://github.com/angular/angular-cli/commit/b0b3a47ae95f4e98e1280ff5c9e3751ccecee5a8) | fix | support resolving subproject dependencies in pnpm workspaces | +| [ce1b60f89](https://github.com/angular/angular-cli/commit/ce1b60f89699c3a76496a0489e5f2b9e4fe62429) | fix | transform fail() to expect.fail() in refactor-jasmine-vitest | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------- | +| [175273931](https://github.com/angular/angular-cli/commit/175273931ebb0cf08bf62dd1694dbde5ff5229b6) | feat | Support splitting browser and server stats jsonfiles for easier consumption | +| [1ee0beca7](https://github.com/angular/angular-cli/commit/1ee0beca7b243cc4c06eb42b25d216d7b256b097) | fix | correct misleading error message for top-level await | +| [0ffe2d27f](https://github.com/angular/angular-cli/commit/0ffe2d27f256a5755809fa4788e4adb97c65b806) | fix | disable code splitting for unit test builds | +| [50994d76e](https://github.com/angular/angular-cli/commit/50994d76e59a4b632f1782c51cd60f80c9cd50fb) | fix | preserve integrity and crossorigin in autoCsp loader | +| [d6fd24320](https://github.com/angular/angular-cli/commit/d6fd243207ad1c3e242b8592698afa986320cc6c) | perf | traverse AST with iterative post-order walker in i18n inliner | + + + + + +# 22.1.5 (2026-08-19) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [e672271f8](https://github.com/angular/angular-cli/commit/e672271f81e7b8b422b98c17fca5b67896d40844) | fix | enforce MCP roots in get_best_practices tool | +| [a14916cc4](https://github.com/angular/angular-cli/commit/a14916cc4b13412205d3e4a1c32d7e40be52f0e1) | fix | handle errors from isAllowedWorkspacePath in best-practices tool | +| [d6e1cddff](https://github.com/angular/angular-cli/commit/d6e1cddff92842f6889585ae7899efc028decc78) | fix | throw on out-of-roots workspace in best-practices tool | ### @schematics/angular -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- | -| [b345bb54d](https://github.com/angular/angular-cli/commit/b345bb54d3ec7c73f9db70400f823d90aeab8fa8) | fix | remove unused spec tsconfig outDir | +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [ba2b0e4c2](https://github.com/angular/angular-cli/commit/ba2b0e4c291842c699fdbeaaddec1531dbdd7952) | fix | transform fail() to expect.fail() in refactor-jasmine-vitest | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [cba72902d](https://github.com/angular/angular-cli/commit/cba72902d42022bd5a1bd17b23d65cacc0013e35) | fix | correct misleading error message for top-level await | +| [b4679998c](https://github.com/angular/angular-cli/commit/b4679998c205b6cce01d40fad24c4ee7954aa48d) | fix | disable code splitting for unit test builds | +| [ff1d3565e](https://github.com/angular/angular-cli/commit/ff1d3565e87749a8347c3be98cd4a7ebccb69fc0) | fix | preserve integrity and crossorigin in autoCsp loader | +| [1fc1fb05c](https://github.com/angular/angular-cli/commit/1fc1fb05cfe2c69ee7b9345b582d3c2177b923fd) | perf | traverse AST with iterative post-order walker in i18n inliner | + + + + + +# 22.2.0-next.3 (2026-08-13) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------- | +| [7106676e6](https://github.com/angular/angular-cli/commit/7106676e64ee234641b1b3cbd88fc058703d4d3e) | fix | disable searching current directory for bare executable names on Windows | +| [ecf8c0822](https://github.com/angular/angular-cli/commit/ecf8c0822e5d56b44a0be4be8fccb41373135d3e) | fix | serialize configuration as a single argv token in run_target strategies ([#33657](https://github.com/angular/angular-cli/pull/33657)) | +| [2e4dd90c3](https://github.com/angular/angular-cli/commit/2e4dd90c3ae898bf36af42f699c7b7d560bfc66a) | perf | avoid eager module loading during global bootstrap | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [1161e6c99](https://github.com/angular/angular-cli/commit/1161e6c99992c884e2e0ce9fc295b0e60b2ae1df) | fix | generate CLAUDE.md for Claude Code instead of AGENTS.md | +| [c536ae364](https://github.com/angular/angular-cli/commit/c536ae364975dd0088fd8717464c723a4dcb74e3) | fix | import UrlSegment instead of subPath in guard generator | ### @angular/build +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [04b532772](https://github.com/angular/angular-cli/commit/04b532772f6347d341b182135dee1758616a69d6) | fix | count statically imported chunks in the initial total | +| [2274babb6](https://github.com/angular/angular-cli/commit/2274babb662b8da2189f1ae9faaa528838052c32) | fix | normalize setupFiles paths to POSIX for vitest runner | +| [199a864df](https://github.com/angular/angular-cli/commit/199a864df97fc8c20b8d4e1fc8b891c61140671a) | fix | prevent syntax corruption for Crockford-style enum IIFE | +| [0d9851600](https://github.com/angular/angular-cli/commit/0d9851600ca618ccabfc3143a9c552a5448d6eec) | fix | recursively ignore output and cache paths in watch mode | +| [3eba6f726](https://github.com/angular/angular-cli/commit/3eba6f7269f3b0f8556510d0b668e978ea686c88) | fix | return direct file contents for non-Angular TypeScript files | +| [3c7ac1518](https://github.com/angular/angular-cli/commit/3c7ac1518b062f14409b4d3c6c3e65c956424ad9) | fix | return only lowest version per target engine | +| [3e9fed9a6](https://github.com/angular/angular-cli/commit/3e9fed9a6a1d24f30812ca29ca621d9d9a14496f) | fix | set target for Rolldown dependency prebundling in Vite dev server | +| [87551ad5c](https://github.com/angular/angular-cli/commit/87551ad5c53f90cbdc68fdb5e47007b9436c7f75) | perf | avoid encoding intermediate source maps before remapping | +| [194be2088](https://github.com/angular/angular-cli/commit/194be2088587918ed7b47ed606ea587a7116608a) | perf | avoid encoding the inline source map before remapping | +| [a55a6b78e](https://github.com/angular/angular-cli/commit/a55a6b78e89debb9c93c83375c54f6716bc26c65) | perf | batch last_accessed updates in sqlite cache store | +| [33b305416](https://github.com/angular/angular-cli/commit/33b3054169929dc98f93a758a90da5d26cf522f7) | perf | hash the i18n inline cache key options once per locale | +| [1c00edce0](https://github.com/angular/angular-cli/commit/1c00edce02bdd0201546417d71518f78bfb89d34) | perf | optimize sourcemap stripping and loading with buffer fast path | +| [b6269a816](https://github.com/angular/angular-cli/commit/b6269a8169046a78281808dcf92054140912a77d) | perf | optimize template string size calculation in server manifest | +| [a6ef9cfbe](https://github.com/angular/angular-cli/commit/a6ef9cfbeace725d58c0f7f65640ef6de9b39c33) | perf | replace watchpack with @parcel/watcher and chokidar | +| [596847f89](https://github.com/angular/angular-cli/commit/596847f89913684a84465d004aa3d6c7e1a40b06) | perf | share i18n translations with the inliner workers by reference | +| [19c91e48d](https://github.com/angular/angular-cli/commit/19c91e48d4b8a95a76dfe4bffd105130b416843d) | perf | use Map for chunk asset size lookups in budget calculator | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [6a3446770](https://github.com/angular/angular-cli/commit/6a3446770a8fbfecc7265eb2a6d764e2f1614b3a) | fix | destroy platform when response stream is cancelled | + + + + + +# 22.1.4 (2026-08-13) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------- | +| [67a29899b](https://github.com/angular/angular-cli/commit/67a29899b07f760bac9582cbc6175966cce1c5d2) | fix | disable searching current directory for bare executable names on Windows | +| [1b0ba5c17](https://github.com/angular/angular-cli/commit/1b0ba5c17f0be2679b31bad1bee8b435315dac1a) | fix | serialize configuration as a single argv token in run_target strategies ([#33657](https://github.com/angular/angular-cli/pull/33657)) | +| [9440432d2](https://github.com/angular/angular-cli/commit/9440432d273c044965508d80e808c2fe123af0aa) | perf | avoid eager module loading during global bootstrap | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [d4a48e3ab](https://github.com/angular/angular-cli/commit/d4a48e3abe320e090eefe17cb70abdf8e7eafaed) | fix | generate CLAUDE.md for Claude Code instead of AGENTS.md | +| [d451f15b2](https://github.com/angular/angular-cli/commit/d451f15b2838a8282363dcb30161dd5dc858750f) | fix | import UrlSegment instead of subPath in guard generator | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [46fcb29d6](https://github.com/angular/angular-cli/commit/46fcb29d6c1ded962de95449d63716c13950f59a) | fix | count statically imported chunks in the initial total | +| [796b57684](https://github.com/angular/angular-cli/commit/796b576843191a4d5c74e4f9363ec0e46ae3a881) | fix | normalize setupFiles paths to POSIX for vitest runner | +| [c805e5cfb](https://github.com/angular/angular-cli/commit/c805e5cfb308d02a1944ef446d0a01af8a7ec6d2) | fix | prevent syntax corruption for Crockford-style enum IIFE | +| [cc6c17716](https://github.com/angular/angular-cli/commit/cc6c17716df5c411efca5e6f4c960f0565f45d90) | fix | prevent syntax corruption in oxc transform | +| [a142f6f83](https://github.com/angular/angular-cli/commit/a142f6f83d2f501b64f2404909ef9325bbfa70fb) | fix | return direct file contents for non-Angular TypeScript files | +| [956c6578d](https://github.com/angular/angular-cli/commit/956c6578d31d8482c04dc45723d7ab3b4fe355a5) | fix | return only lowest version per target engine | +| [689b5b116](https://github.com/angular/angular-cli/commit/689b5b116717140da98494e61ac5ea5ad7b68d73) | fix | set target for Rolldown dependency prebundling in Vite dev server | +| [70461f6ed](https://github.com/angular/angular-cli/commit/70461f6ed2e30aeaad2b93400ad00d9274665967) | perf | avoid encoding the inline source map before remapping | +| [22cce7563](https://github.com/angular/angular-cli/commit/22cce7563e82f17373b69e590d3e445767bd9264) | perf | batch last_accessed updates in sqlite cache store | +| [ab2ed18d5](https://github.com/angular/angular-cli/commit/ab2ed18d5739e8565403062ca74aa2d5cb3b95ec) | perf | hash the i18n inline cache key options once per locale | +| [34516ff73](https://github.com/angular/angular-cli/commit/34516ff736185e20ef8239b1fed8eda1f0193f62) | perf | optimize template string size calculation in server manifest | +| [712971c37](https://github.com/angular/angular-cli/commit/712971c370738beaebff18f6995859b1713ed8ae) | perf | share i18n translations with the inliner workers by reference | +| [78f8c5fef](https://github.com/angular/angular-cli/commit/78f8c5fef44dc6ff466b17c98fa1d01222cb1781) | perf | use Map for chunk asset size lookups in budget calculator | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [2331c1047](https://github.com/angular/angular-cli/commit/2331c10476830e7a53e0f2fa4a2f37dad58e2d5d) | fix | destroy platform when response stream is cancelled | + + + + + +# 21.2.21 (2026-08-12) + +### @angular/cli + | Commit | Type | Description | | --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | -| [0c18dc0f6](https://github.com/angular/angular-cli/commit/0c18dc0f6d4f36d7bbb6f35b37bfa688e8fcf7d2) | feat | emit debug ids for stable subresource integrity hashes | -| [291caab61](https://github.com/angular/angular-cli/commit/291caab6158fa804f6221dd024a5b96e6181eaad) | fix | prevent stripping nested sourceMappingURL comments | +| [ba5d16cac](https://github.com/angular/angular-cli/commit/ba5d16cacab4139897d1f0cf4161562b1bd00553) | fix | update dependency @modelcontextprotocol/sdk to v1.30.0 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [f7f60e69e](https://github.com/angular/angular-cli/commit/f7f60e69e77cda60378543a9ca9cc2c76c0bf34f) | fix | bump undici to 7.29.0 | - + -# 22.0.7 (2026-07-15) +# 20.3.34 (2026-08-12) ### @angular/cli -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | -| [e85c10ccb](https://github.com/angular/angular-cli/commit/e85c10ccb551eeaf9ac7e819ee6eb095317b5032) | fix | copy packageManager field and yarn config for temp installs | -| [b87cafec0](https://github.com/angular/angular-cli/commit/b87cafec04b24c2f4337ecfa00388197ab5003f0) | fix | support resolving subproject dependencies in pnpm workspaces | +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [3962517b9](https://github.com/angular/angular-cli/commit/3962517b95c76016ff7bcb53662704dff18c0552) | fix | update dependency @modelcontextprotocol/sdk to v1.30.0 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [f348efe8b](https://github.com/angular/angular-cli/commit/f348efe8b6d9c9d7014afb331eecda0a9478ba0a) | fix | bump undici to 7.29.0 | - + -# 21.2.19 (2026-07-09) +# 22.2.0-next.2 (2026-08-05) + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [7e5510e464](https://github.com/angular/angular-cli/commit/7e5510e464eb423d8757ae6f058f73fc17249d81) | fix | disable linker internal source mapping in OXC linker | +| [bad31421cb](https://github.com/angular/angular-cli/commit/bad31421cbf23339d4fbd389a91d198df405697d) | fix | prevent syntax corruption in linker and oxc transform | + + + + + +# 22.2.0-next.1 (2026-08-05) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| [41555dfb3b](https://github.com/angular/angular-cli/commit/41555dfb3b71d08cdfe2853bf2cbeca5b6942f67) | feat | add `--root` command line option to `mcp` command | +| [035b9d5c9b](https://github.com/angular/angular-cli/commit/035b9d5c9bca624b98e14480f599481f294b49f3) | fix | add packages with built-in schematics to devDependencies only | +| [0ae5690c35](https://github.com/angular/angular-cli/commit/0ae5690c35b9af00f60d4851f1bab4ba5ec8eb19) | fix | always install package during ng add to inspect manifest on disk for schematics ([#33718](https://github.com/angular/angular-cli/pull/33718)) | +| [44ff584b33](https://github.com/angular/angular-cli/commit/44ff584b33830aa839c08feb205f014e4e7b5dca) | fix | correctly report available package updates during bare ng update | +| [1d4f9ebbe9](https://github.com/angular/angular-cli/commit/1d4f9ebbe993a5eb8b23929a3fc55b506597e5aa) | fix | discover migrations from installed packages when omitted by registry metadata ([#33718](https://github.com/angular/angular-cli/pull/33718)) | +| [25034f97ad](https://github.com/angular/angular-cli/commit/25034f97ad1b9a12f4080f646a0e2705425f9726) | fix | normalize Windows drive-letter casing for process.cwd | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------- | +| [ad8271320f](https://github.com/angular/angular-cli/commit/ad8271320f67c2b1502e446f1e3cb0a05444d04f) | feat | migrate Angular Linker to oxc-parser and magic-string | +| [4fb12292c2](https://github.com/angular/angular-cli/commit/4fb12292c21498c59836f7c80e4f824bb7751cac) | fix | prevent build process hang on unhandled error ([#33715](https://github.com/angular/angular-cli/pull/33715)) | +| [abd8ec9f3e](https://github.com/angular/angular-cli/commit/abd8ec9f3e11f1410fcb87d0d9d60f56016912fe) | fix | prevent IIFE wrapper interleaving for adjacent classes in minified files | +| [bcadbbef20](https://github.com/angular/angular-cli/commit/bcadbbef2054e897b9d22c1456883fc68f0383c0) | fix | retain watch files on error in load result cache | +| [e1c7193dce](https://github.com/angular/angular-cli/commit/e1c7193dcee1c7f2feb07fdf24a6e0656f29cfaf) | fix | support case-insensitive and alternative license file names | +| [f4a0f846e7](https://github.com/angular/angular-cli/commit/f4a0f846e7d14b5ff58c91d04461ef4a027c56b8) | perf | release build resources early in non-watch mode ([#33715](https://github.com/angular/angular-cli/pull/33715)) | + +### @angular/ssr + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [b74eef889f](https://github.com/angular/angular-cli/commit/b74eef889f9c8812dcbe116400663fa3d68d6b4f) | fix | settle writeResponseToNodeResponse when client disconnects | +| [33169801c6](https://github.com/angular/angular-cli/commit/33169801c6ed7fc7c7dca2c04008e5d405374983) | perf | avoid buffering request body when sanitizing headers | + + + + + +# 22.1.3 (2026-08-05) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| [f837b788c6](https://github.com/angular/angular-cli/commit/f837b788c6a23e7661db5455192f5a84d20e9b37) | fix | always install package during ng add to inspect manifest on disk for schematics ([#33718](https://github.com/angular/angular-cli/pull/33718)) | +| [72ae93638d](https://github.com/angular/angular-cli/commit/72ae93638dd83b3877773ac0d9f7e4c4e9056a33) | fix | correctly report available package updates during bare ng update | +| [a949e06568](https://github.com/angular/angular-cli/commit/a949e0656855786e4274159be09242ff9c4eb012) | fix | discover migrations from installed packages when omitted by registry metadata ([#33718](https://github.com/angular/angular-cli/pull/33718)) | +| [1d9122fd50](https://github.com/angular/angular-cli/commit/1d9122fd50f8a9b82199d44f9b0e075726279538) | fix | normalize Windows drive-letter casing for process.cwd | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------- | +| [a4860d505a](https://github.com/angular/angular-cli/commit/a4860d505aff0b43fc608cd0745ed187b1d407b5) | fix | prevent build process hang on unhandled error ([#33715](https://github.com/angular/angular-cli/pull/33715)) | +| [7686d66cab](https://github.com/angular/angular-cli/commit/7686d66cabcdfe90748ac405686d42674a19576f) | fix | retain watch files on error in load result cache | +| [25b7cf7504](https://github.com/angular/angular-cli/commit/25b7cf7504838d541abfba043d9ea3d616a23c04) | fix | support case-insensitive and alternative license file names | +| [4fd81402ce](https://github.com/angular/angular-cli/commit/4fd81402ce5f2f3db45a17ee0d116f12edf8d254) | perf | release build resources early in non-watch mode ([#33715](https://github.com/angular/angular-cli/pull/33715)) | + +### @angular/ssr + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [6af906cb94](https://github.com/angular/angular-cli/commit/6af906cb94bd0686b4c0431989e054711c74892a) | fix | settle writeResponseToNodeResponse when client disconnects | +| [917d3b2254](https://github.com/angular/angular-cli/commit/917d3b22549c19b91e03ceb0775a3a758142b9de) | perf | avoid buffering request body when sanitizing headers | + + + + + +# 21.2.20 (2026-08-05) ### @angular-devkit/build-angular -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | -| [df5429871](https://github.com/angular/angular-cli/commit/df54298712bb84ec079787e4ca6c0164f6358cf1) | fix | update http-proxy-middleware to 3.0.7 | +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [972f6db7e9](https://github.com/angular/angular-cli/commit/972f6db7e9ccf3c4b0e895341a49b104a8b0ac9b) | fix | upgrade postcss to 8.5.23 | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [8de75ada50](https://github.com/angular/angular-cli/commit/8de75ada503d4182af7f0be957e58fe678a63b98) | fix | upgrade postcss to 8.5.23 | - + -# 20.3.32 (2026-07-09) +# 20.3.33 (2026-08-05) ### @angular-devkit/build-angular -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | -| [d4e07cb3e](https://github.com/angular/angular-cli/commit/d4e07cb3e3f871822f9bf37fdea35814ec1cdb80) | fix | update http-proxy-middleware to 3.0.7 | +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [0b4008f17d](https://github.com/angular/angular-cli/commit/0b4008f17d76c821d9f075b86da80b826f085d23) | fix | upgrade postcss to 8.5.23 | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [fc861affcd](https://github.com/angular/angular-cli/commit/fc861affcd8866b71b81c8ba90f7a63890b6577c) | fix | upgrade postcss to 8.5.23 | - + -# 22.1.0-next.3 (2026-07-09) +# 22.1.2 (2026-07-30) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [57db27ea91](https://github.com/angular/angular-cli/commit/57db27ea910b102854267dfcd77e8fe60f23993c) | fix | add packages with built-in schematics to devDependencies only | ### @angular/build -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ | -| [34d558c3c](https://github.com/angular/angular-cli/commit/34d558c3c98e1b59a2c2808fc04bfbb63dfe8acf) | feat | add built-in SQLite cache store fallback | -| [52ae7f862](https://github.com/angular/angular-cli/commit/52ae7f862a581cac97d5a9b33c9b71b830d95b19) | feat | share persistent build cache across git worktrees | -| [41bd100dd](https://github.com/angular/angular-cli/commit/41bd100dd8edebdf6c7279e1c7d3847ee5f22702) | fix | fallback to build target preserveSymlinks option in karma runner | -| [6d9b360e5](https://github.com/angular/angular-cli/commit/6d9b360e5f2b7ab3d4ace4db35f674166630f658) | fix | prevent esbuild service hang on internal component stylesheet builds | -| [85c53d990](https://github.com/angular/angular-cli/commit/85c53d9909b8bcb35a360044a0afde5352d45d72) | fix | prevent externalizing builder-injected i18n locale data | -| [004cc4104](https://github.com/angular/angular-cli/commit/004cc410487b2dbb80001f045cd19f899c980ccc) | fix | strip all vite id prefixes from minified code with external dependencies | -| [09d0a11a8](https://github.com/angular/angular-cli/commit/09d0a11a8b832f156aea100ad3497c5221fc0f50) | perf | enable fast-path AST printing with sourcemaps in AotCompilation | +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ | +| [baed978885](https://github.com/angular/angular-cli/commit/baed978885585fceadc64f0151d7474054d57d6a) | fix | prevent IIFE wrapper interleaving for adjacent classes in minified files | -### @angular/ssr + -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | -| [0bd22dae0](https://github.com/angular/angular-cli/commit/0bd22dae09a83e36ed3863ca2b050666cc8d7aec) | fix | replace all route parameters when resolving relative redirects | + + +# 22.1.1 (2026-07-30) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [ab971a8543](https://github.com/angular/angular-cli/commit/ab971a8543d80dfc93fdddf49598564f25196160) | fix | support npm 12 metadata array and error formats | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [db24220fb8](https://github.com/angular/angular-cli/commit/db24220fb8efb5c9416e375a468672ff69d0659d) | fix | rewrite paths from sandboxed execroots when running under Bazel | - + -# 22.0.6 (2026-07-08) +# 22.2.0-next.0 (2026-07-30) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [e148a8df93](https://github.com/angular/angular-cli/commit/e148a8df93b309dcd51698b4ce4f9343560edd8e) | fix | support npm 12 metadata array and error formats | ### @angular/build -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ | -| [df49966c6](https://github.com/angular/angular-cli/commit/df49966c6b8ec0c10b58fee18981fc2992ac071a) | fix | fallback to build target preserveSymlinks option in karma runner | -| [9906add5b](https://github.com/angular/angular-cli/commit/9906add5b722cca7f61488b367dd73545c6e13bc) | fix | prevent externalizing builder-injected i18n locale data | -| [070155f6e](https://github.com/angular/angular-cli/commit/070155f6ead185d4b478d9034662d65b8bb86a23) | fix | strip all vite id prefixes from minified code with external dependencies | +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [6469b5f1f5](https://github.com/angular/angular-cli/commit/6469b5f1f5946415f49000f26bddd88b5647ac3d) | fix | rewrite paths from sandboxed execroots when running under Bazel | + + + + + +# 22.1.0 (2026-07-29) + +## Deprecations + +### @angular-devkit/core + +- `stringToFileBuffer` and `fileBufferToString` are deprecated. Use standard Web APIs (`TextEncoder` and `TextDecoder`) instead. + + Internal usages within the repository have been removed and replaced with standard Web APIs. The public API golden file for `@angular-devkit/core` has been updated to reflect the deprecations. + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [89d7f59cd](https://github.com/angular/angular-cli/commit/89d7f59cd7cc5d821ac0e81b1fee50e27877c976) | feat | update ai-config to include Angular MCP server config | +| [b345bb54d](https://github.com/angular/angular-cli/commit/b345bb54d3ec7c73f9db70400f823d90aeab8fa8) | fix | remove unused spec tsconfig outDir | + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [f59bcf529](https://github.com/angular/angular-cli/commit/f59bcf52917bc5e25b97c863f96127ec8d0f12f5) | fix | abort and guide manual mitigation when updating catalog packages | +| [48f90f5d5](https://github.com/angular/angular-cli/commit/48f90f5d5e5dd6369b2ccf044a722271c82dffd6) | fix | prevent Yarn registry environment variable override | +| [c963ecbec](https://github.com/angular/angular-cli/commit/c963ecbec241d42dfb419ae081ba22429e37f09e) | fix | remove algoliasearch dependency and support latest docs versions | +| [6859d25c3](https://github.com/angular/angular-cli/commit/6859d25c32b6c93cbbf2d4ad4143aa10b52b7588) | fix | resolve correct registry name when using npm alias syntax during update | +| [3a2589dac](https://github.com/angular/angular-cli/commit/3a2589dac684dfe26b2151b940bd40280d7c5d8e) | fix | respect client-side release age settings during update resolution | +| [5c3101bff](https://github.com/angular/angular-cli/commit/5c3101bff2e1b9d0b0e4668a183e8bcab8a1540e) | fix | revert package.json changes if installation tasks fail during update | +| [7932caaf9](https://github.com/angular/angular-cli/commit/7932caaf987c5692d6624f6af23e65ce3f6d27fd) | fix | robustly parse npm manifest from array | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------- | +| [fd336d365](https://github.com/angular/angular-cli/commit/fd336d365dbfe8f558db177a8da24790914a541b) | refactor | deprecate stringToFileBuffer and fileBufferToString | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------- | +| [34d558c3c](https://github.com/angular/angular-cli/commit/34d558c3c98e1b59a2c2808fc04bfbb63dfe8acf) | feat | add built-in SQLite cache store fallback | +| [0c18dc0f6](https://github.com/angular/angular-cli/commit/0c18dc0f6d4f36d7bbb6f35b37bfa688e8fcf7d2) | feat | emit debug ids for stable subresource integrity hashes | +| [51f69276f](https://github.com/angular/angular-cli/commit/51f69276fd2b2aab0e94901e79a51f6860ef8690) | feat | enable chunk optimization for server builds | +| [10dc30f9c](https://github.com/angular/angular-cli/commit/10dc30f9c680f46f65b5beb030f2a75c422a3e71) | feat | migrate advanced optimization Babel plugins to oxc-parser + magic-string | +| [917393a4c](https://github.com/angular/angular-cli/commit/917393a4cd85172574b155f727da5c1eae196fb1) | feat | migrate i18n inliner to oxc-parser + magic-string | +| [52ae7f862](https://github.com/angular/angular-cli/commit/52ae7f862a581cac97d5a9b33c9b71b830d95b19) | feat | share persistent build cache across git worktrees | +| [95510511d](https://github.com/angular/angular-cli/commit/95510511d963384c5a03298300246aa34a62328a) | fix | add bounded timeout to vitest executor disposal | +| [62b11e316](https://github.com/angular/angular-cli/commit/62b11e316e8248161877592acee5954b2a8c9c66) | fix | anchor debug ID comment matching and make injection idempotent | +| [6d9b360e5](https://github.com/angular/angular-cli/commit/6d9b360e5f2b7ab3d4ace4db35f674166630f658) | fix | prevent esbuild service hang on internal component stylesheet builds | +| [291caab61](https://github.com/angular/angular-cli/commit/291caab6158fa804f6221dd024a5b96e6181eaad) | fix | prevent stripping nested sourceMappingURL comments | +| [0e3ca3dfb](https://github.com/angular/angular-cli/commit/0e3ca3dfba8ef60217089d93a321660ebb6fb838) | fix | support hyphens and underscores when stripping chunk hash in chunk optimizer | +| [585d08af8](https://github.com/angular/angular-cli/commit/585d08af881a35653dc5e7b1ee7a75b42e154517) | perf | default chunk optimization to use Rolldown | +| [09d0a11a8](https://github.com/angular/angular-cli/commit/09d0a11a8b832f156aea100ad3497c5221fc0f50) | perf | enable fast-path AST printing with sourcemaps in AotCompilation | ### @angular/ssr -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | -| [c9aa354b1](https://github.com/angular/angular-cli/commit/c9aa354b1de0c946ddc994191613bb809a799f81) | fix | replace all route parameters when resolving relative redirects | +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [7ef9ed24d](https://github.com/angular/angular-cli/commit/7ef9ed24d3c111474ae21c06cb2b05a21804f586) | feat | support the standard Forwarded header | - + -# 22.1.0-next.2 (2026-07-01) +# 22.0.9 (2026-07-29) ### @angular/cli +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [9ee8f9b669](https://github.com/angular/angular-cli/commit/9ee8f9b6692b8efa2d0cabd502cd5a598f436c8a) | fix | declare devserver_start and devserver_stop as isReadOnly:false | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [d0c776d461](https://github.com/angular/angular-cli/commit/d0c776d4610e2327c29900b9b3d679e9c0f28061) | fix | safeguard Karma builder stream controller against closed state | + + + + + +# 22.0.8 (2026-07-23) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [41c356256](https://github.com/angular/angular-cli/commit/41c356256525469a41ea72896b1ddff91a4e6e55) | fix | batch Prettier invocations during migration formatting | +| [bc87c85c3](https://github.com/angular/angular-cli/commit/bc87c85c3c660e969cf77a493dc4ee58fd831214) | fix | update MCP devserver tool names to conform to spec regex | + +### @angular/build + | Commit | Type | Description | | --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | -| [010cef665](https://github.com/angular/angular-cli/commit/010cef6657365cad8529c164d7c594ca598180a0) | fix | gracefully handle package manager errors in command handler | -| [3a2589dac](https://github.com/angular/angular-cli/commit/3a2589dac684dfe26b2151b940bd40280d7c5d8e) | fix | respect client-side release age settings during update resolution | -| [edb4594de](https://github.com/angular/angular-cli/commit/edb4594deb5ff96967c1ce13b4494e3fd675ebec) | fix | respect release age policy in update bootstrapping logic | +| [5724bf632](https://github.com/angular/angular-cli/commit/5724bf632eba59eccdd941beae8aafad35ce003e) | fix | canonicalize drive letter casing for workspace root on Windows | +| [b8bdbaa1e](https://github.com/angular/angular-cli/commit/b8bdbaa1ea1c58f061580bd58c015078f57c1530) | fix | ensure import map integrity keys are valid URL-like specifiers | +| [212a7bb21](https://github.com/angular/angular-cli/commit/212a7bb21eca131f32fd7b4e7b5a9ab87f2bfded) | fix | favor istanbul coverage provider when browser testing is enabled | +| [bc760b005](https://github.com/angular/angular-cli/commit/bc760b005e0dae10a2dfd31a7f0f772482305398) | fix | preserve custom config options in runnerConfig for vitest | +| [19cda7415](https://github.com/angular/angular-cli/commit/19cda7415693f36e911a736a32576110905b6600) | fix | remap metafile paths when workspace root is a symlink or junction | +| [fdcc98c79](https://github.com/angular/angular-cli/commit/fdcc98c7912f45431268fdf66c8b132bd119bc27) | perf | skip semantic affected-file walk when type checking is disabled | -### @schematics/angular + -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- | -| [0b3172e07](https://github.com/angular/angular-cli/commit/0b3172e076b75df5b8c3066a115d1151f8b0bad9) | fix | remove unused app tsconfig outDir | + + +# 22.0.7 (2026-07-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [e85c10ccb](https://github.com/angular/angular-cli/commit/e85c10ccb551eeaf9ac7e819ee6eb095317b5032) | fix | copy packageManager field and yarn config for temp installs | +| [b87cafec0](https://github.com/angular/angular-cli/commit/b87cafec04b24c2f4337ecfa00388197ab5003f0) | fix | support resolving subproject dependencies in pnpm workspaces | + + + + + +# 21.2.19 (2026-07-09) ### @angular-devkit/build-angular -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | -| [2d3eb7fb3](https://github.com/angular/angular-cli/commit/2d3eb7fb3b43b64c476296581481dbf67cb57b1d) | fix | prevent OS command injection in ssr-dev-server builder | +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [df5429871](https://github.com/angular/angular-cli/commit/df54298712bb84ec079787e4ca6c0164f6358cf1) | fix | update http-proxy-middleware to 3.0.7 | + + + + + +# 20.3.32 (2026-07-09) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [d4e07cb3e](https://github.com/angular/angular-cli/commit/d4e07cb3e3f871822f9bf37fdea35814ec1cdb80) | fix | update http-proxy-middleware to 3.0.7 | + + + + + +# 22.0.6 (2026-07-08) ### @angular/build -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | -| [75c1dce38](https://github.com/angular/angular-cli/commit/75c1dce387075d3ea9d6da572f7daa589856bc0e) | fix | auto-inject localize/init in library unit tests | -| [dc27e17fc](https://github.com/angular/angular-cli/commit/dc27e17fc2cbb91b10b508a7e46cd72734959ffa) | fix | inherit preserveSymlinks option from build target in unit-test builder | -| [61e25fb1d](https://github.com/angular/angular-cli/commit/61e25fb1db56694da800f330dd65cb7e50ea2d11) | fix | load zone.js dynamically for library unit tests | -| [0010b922f](https://github.com/angular/angular-cli/commit/0010b922f8fc5a9c57b9512594f9c3b4da3fa2ae) | fix | scope createRequire module resolution using paths to prevent parent paths | +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ | +| [df49966c6](https://github.com/angular/angular-cli/commit/df49966c6b8ec0c10b58fee18981fc2992ac071a) | fix | fallback to build target preserveSymlinks option in karma runner | +| [9906add5b](https://github.com/angular/angular-cli/commit/9906add5b722cca7f61488b367dd73545c6e13bc) | fix | prevent externalizing builder-injected i18n locale data | +| [070155f6e](https://github.com/angular/angular-cli/commit/070155f6ead185d4b478d9034662d65b8bb86a23) | fix | strip all vite id prefixes from minified code with external dependencies | ### @angular/ssr -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | -| [d9fb854c9](https://github.com/angular/angular-cli/commit/d9fb854c9a48ba8852d57f3fa8f7a9640ee0aab2) | fix | preserve response headers during redirect | +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [c9aa354b1](https://github.com/angular/angular-cli/commit/c9aa354b1de0c946ddc994191613bb809a799f81) | fix | replace all route parameters when resolving relative redirects | @@ -247,43 +859,6 @@ - - -# 22.1.0-next.1 (2026-06-24) - -### @angular/cli - -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | -| [48f90f5d5](https://github.com/angular/angular-cli/commit/48f90f5d5e5dd6369b2ccf044a722271c82dffd6) | fix | prevent Yarn registry environment variable override | -| [38e0fabc0](https://github.com/angular/angular-cli/commit/38e0fabc05eb5d622a25c68692d7d073485750e3) | fix | support registry metadata fetching under bun package manager | -| [3ce6e5fc3](https://github.com/angular/angular-cli/commit/3ce6e5fc3b4f4b5fb6ac99cc82023bf01894d14c) | perf | implement semaphore backpressure throttling in PackageManager | - -### @schematics/angular - -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | -| [01008f1c6](https://github.com/angular/angular-cli/commit/01008f1c69321d0a8b5b8344672b594a68fe83b6) | fix | remove default workspace vscode mcp.json configuration | - -### @angular/build - -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | -| [851711b03](https://github.com/angular/angular-cli/commit/851711b031603293b58e33357c70eea7675452e3) | fix | aggregate parallel worker performance timings on the main thread | -| [585d08af8](https://github.com/angular/angular-cli/commit/585d08af881a35653dc5e7b1ee7a75b42e154517) | perf | default chunk optimization to use Rolldown | -| [3019c8032](https://github.com/angular/angular-cli/commit/3019c8032ba9462d0edc614940b26ade24e45ec8) | perf | dispose builder result context early in non-watch mode | -| [583736a4d](https://github.com/angular/angular-cli/commit/583736a4de8b3d0b4d79709a445809c264e38c17) | perf | implement semaphore backpressure throttling in JavaScriptTransformer | - -### @angular/ssr - -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | -| [7ef9ed24d](https://github.com/angular/angular-cli/commit/7ef9ed24d3c111474ae21c06cb2b05a21804f586) | feat | support the standard Forwarded header | -| [37f1a7491](https://github.com/angular/angular-cli/commit/37f1a749132d55a81cec231bd260ed280b10fc00) | fix | avoid caching non-SSG page lookups | -| [5875b6024](https://github.com/angular/angular-cli/commit/5875b6024ff9e59fddf7481b6b961d73ae809963) | fix | prioritize options over environment variables in AngularNodeAppEngine | - - - # 22.0.4 (2026-06-24) @@ -402,44 +977,6 @@ - - -# 22.1.0-next.0 (2026-06-11) - -## Deprecations - -### @angular-devkit/core - -- `stringToFileBuffer` and `fileBufferToString` are deprecated. Use standard Web APIs (`TextEncoder` and `TextDecoder`) instead. - - Internal usages within the repository have been removed and replaced with standard Web APIs. The public API golden file for `@angular-devkit/core` has been updated to reflect the deprecations. - -### @schematics/angular - -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | -| [89d7f59cd](https://github.com/angular/angular-cli/commit/89d7f59cd7cc5d821ac0e81b1fee50e27877c976) | feat | update ai-config to include Angular MCP server config | - -### @angular/cli - -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | -| [7932caaf9](https://github.com/angular/angular-cli/commit/7932caaf987c5692d6624f6af23e65ce3f6d27fd) | fix | robustly parse npm manifest from array | - -### @angular-devkit/core - -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------- | -| [fd336d365](https://github.com/angular/angular-cli/commit/fd336d365dbfe8f558db177a8da24790914a541b) | refactor | deprecate stringToFileBuffer and fileBufferToString | - -### @angular/ssr - -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | -| [ddcef9d48](https://github.com/angular/angular-cli/commit/ddcef9d484e205c5fcfd93d184425c1c1af19c34) | fix | correct grammar in console warning for redirected location headers | - - - # 20.3.28 (2026-06-11) diff --git a/MODULE.bazel b/MODULE.bazel index 4b82a12dc781..5e0096477618 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -5,50 +5,50 @@ module( ) bazel_dep(name = "platforms", version = "1.1.0") -bazel_dep(name = "yq.bzl", version = "0.3.6") +bazel_dep(name = "yq.bzl", version = "0.4.0") bazel_dep(name = "rules_nodejs", version = "6.7.5") -bazel_dep(name = "aspect_rules_js", version = "3.2.3") -bazel_dep(name = "aspect_rules_ts", version = "3.9.2") -bazel_dep(name = "rules_pkg", version = "1.2.0") -bazel_dep(name = "rules_cc", version = "0.2.22") -bazel_dep(name = "jq.bzl", version = "0.6.1") -bazel_dep(name = "bazel_lib", version = "3.5.0") -bazel_dep(name = "bazel_skylib", version = "1.9.0") -bazel_dep(name = "aspect_rules_esbuild", version = "0.26.0") +bazel_dep(name = "aspect_rules_js", version = "3.4.1") +bazel_dep(name = "aspect_rules_ts", version = "3.10.1") +bazel_dep(name = "rules_pkg", version = "1.3.0") +bazel_dep(name = "rules_cc", version = "0.2.25") +bazel_dep(name = "jq.bzl", version = "0.6.2") +bazel_dep(name = "bazel_lib", version = "3.7.2") +bazel_dep(name = "bazel_skylib", version = "1.9.2") +bazel_dep(name = "aspect_rules_esbuild", version = "0.27.1") bazel_dep(name = "aspect_rules_jasmine", version = "2.0.4") bazel_dep(name = "rules_angular") git_override( module_name = "rules_angular", - commit = "db445dccfe48aad20be6ee3df456df91660ff217", + commit = "6468bfd6c2f90b7a025d86273b070d0dd0a443db", remote = "https://github.com/angular/rules_angular.git", ) bazel_dep(name = "devinfra") git_override( module_name = "devinfra", - commit = "daa0b5ecd15668d12163f70d4c9836ada70808c9", + commit = "bf1d18837b5aaed70cf858ecd177583bf320be50", remote = "https://github.com/angular/dev-infra.git", ) bazel_dep(name = "rules_browsers") git_override( module_name = "rules_browsers", - commit = "3a6bd40f9be945dc51c976a21424c656448f9b09", + commit = "ba55f13997d951c9ac02192cf6d1cadad03c1237", remote = "https://github.com/angular/rules_browsers.git", ) node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node") node.toolchain( node_repositories = { - "22.23.1-darwin_arm64": ("node-v22.23.1-darwin-arm64.tar.gz", "node-v22.23.1-darwin-arm64", "ef28d8fab2c0e4314522d4bb1b7173270aa3937e93b92cb7de79c112ac1fa953"), - "22.23.1-darwin_amd64": ("node-v22.23.1-darwin-x64.tar.gz", "node-v22.23.1-darwin-x64", "b8da981b8a0b1241b70249204916da76c63573ddf5814dbd2d1e41069105cb81"), - "22.23.1-linux_arm64": ("node-v22.23.1-linux-arm64.tar.xz", "node-v22.23.1-linux-arm64", "0294e8b915ab75f92c7513d2fcb830ae06e10684e6c603e99a87dbf8835389c1"), - "22.23.1-linux_ppc64le": ("node-v22.23.1-linux-ppc64le.tar.xz", "node-v22.23.1-linux-ppc64le", "a645bcb7bbc498d09ba9249b5c6414aa6d0a461d8854afc61e32096646ba5e66"), - "22.23.1-linux_s390x": ("node-v22.23.1-linux-s390x.tar.xz", "node-v22.23.1-linux-s390x", "f16b5636ba925b462e627c5e0ef47de3255bf4a7b38d56db58e2cf165df19c3c"), - "22.23.1-linux_amd64": ("node-v22.23.1-linux-x64.tar.xz", "node-v22.23.1-linux-x64", "9749e988f437343b7fa832c69ded82a312e41a03116d766797ac14f6f9eee578"), - "22.23.1-windows_amd64": ("node-v22.23.1-win-x64.zip", "node-v22.23.1-win-x64", "7df0bc9375723f4a86b3aa1b7cc73342423d9677a8df4538aca31a049e309c29"), + "24.21.0-darwin_arm64": ("node-v24.21.0-darwin-arm64.tar.gz", "node-v24.21.0-darwin-arm64", "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057"), + "24.21.0-darwin_amd64": ("node-v24.21.0-darwin-x64.tar.gz", "node-v24.21.0-darwin-x64", "1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097"), + "24.21.0-linux_arm64": ("node-v24.21.0-linux-arm64.tar.xz", "node-v24.21.0-linux-arm64", "6ad1325edbdb5649c379b75a237147a666c95d4f9ae8d340fef2d1575d289ad2"), + "24.21.0-linux_ppc64le": ("node-v24.21.0-linux-ppc64le.tar.xz", "node-v24.21.0-linux-ppc64le", "1936fd64623a2f98d1fb31b456686d10c30e92639899cfefdefa8835d22adccd"), + "24.21.0-linux_s390x": ("node-v24.21.0-linux-s390x.tar.xz", "node-v24.21.0-linux-s390x", "2ef7e2ecbf7a6c2f3d08d106f6b2f279419c6dac89fe91b8cad193af7890082c"), + "24.21.0-linux_amd64": ("node-v24.21.0-linux-x64.tar.xz", "node-v24.21.0-linux-x64", "fd8e59d5a511510f6a298afb548f18c7d2b1be404d8b4a27d94fbe49f56cb2d6"), + "24.21.0-windows_amd64": ("node-v24.21.0-win-x64.zip", "node-v24.21.0-win-x64", "158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541"), }, - node_version = "22.23.1", + node_version = "24.21.0", ) use_repo( node, @@ -131,8 +131,8 @@ use_repo( pnpm = use_extension("@aspect_rules_js//npm:extensions.bzl", "pnpm") pnpm.pnpm( name = "pnpm", - pnpm_version = "11.15.1", - pnpm_version_integrity = "sha512-gTULB+U8lTigLx8jA7QpD6LXvgTlbiqXDEzEtBfcdh3hlu2r1J1Vx9yVgNuBAHxEFD5OPX5GKzAA0jwlUSLQZQ==", + pnpm_version = "12.3.4", + pnpm_version_integrity = "sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==", ) use_repo(pnpm, "pnpm") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index eb446604633b..21303bfb9125 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1,5 +1,5 @@ { - "lockFileVersion": 24, + "lockFileVersion": 28, "registryFileHashes": { "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", @@ -10,6 +10,9 @@ "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915", "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed", "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/source.json": "9be551b8d4e3ef76875c0d744b5d6a504a27e3ae67bc6b28f46415fd2d2957da", + "https://bcr.bazel.build/modules/apple_support/1.23.1/MODULE.bazel": "53763fed456a968cf919b3240427cf3a9d5481ec5466abc9d5dc51bc70087442", + "https://bcr.bazel.build/modules/apple_support/2.8.0/MODULE.bazel": "c45f5176057092afba0be6c48a9ea02101666802f8be536947e099e94757f8ed", + "https://bcr.bazel.build/modules/apple_support/2.8.0/source.json": "b7f6d87669c206adaa7d82cc44733349c63e341b80a0e049496ebd91d78c3472", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.0.0/MODULE.bazel": "e118477db5c49419a88d78ebc7a2c2cea9d49600fe0f490c1903324a2c16ecd9", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.2/MODULE.bazel": "30dfabbfae0139b1f0036e01c201dd4c0167da3017f0b7ef3820d78e07622989", @@ -18,20 +21,21 @@ "https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.5/source.json": "ac2c3213df8f985785f1d0aeb7f0f73d5324e6e67d593d9b9470fb74a25d4a9b", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838", - "https://bcr.bazel.build/modules/aspect_rules_esbuild/0.26.0/MODULE.bazel": "6c902d97038c3ab07b6c4e67c97abc61b20182fcfa84fa7dee82fc724f12e455", - "https://bcr.bazel.build/modules/aspect_rules_esbuild/0.26.0/source.json": "4cc3ece7ab661bb391a9e24fe55c4b567d60a9ea9d9e91d772dad373cbcb6217", + "https://bcr.bazel.build/modules/aspect_rules_esbuild/0.27.1/MODULE.bazel": "99c3978959edd9892e4b513831b218a13cc84a0215bfe1b972a1e3a771c4670e", + "https://bcr.bazel.build/modules/aspect_rules_esbuild/0.27.1/source.json": "20f515102cbcd0835d90bb3976b193eb58c2a0c534093c85cfd3bf6f06dba748", "https://bcr.bazel.build/modules/aspect_rules_jasmine/2.0.4/MODULE.bazel": "fbb819eb8b7e5d7f67fdd38f7cecb413e287594cd666ce192c72c8828527775a", "https://bcr.bazel.build/modules/aspect_rules_jasmine/2.0.4/source.json": "81ffb708333cd98ec3c0b4cc004f4d5cf92a16914b5196a2892c45141bba7cff", "https://bcr.bazel.build/modules/aspect_rules_js/2.0.0/MODULE.bazel": "b45b507574aa60a92796e3e13c195cd5744b3b8aff516a9c0cb5ae6a048161c5", - "https://bcr.bazel.build/modules/aspect_rules_js/3.0.3/MODULE.bazel": "28a30e8fc33bf64a67835d64d124f6e05a7d59648dcb27b110fb3502f761e503", - "https://bcr.bazel.build/modules/aspect_rules_js/3.2.3/MODULE.bazel": "3a0363a5b8ec4931488dc327c577a78c1f5a725293ebffaa5d2613283701aa8a", - "https://bcr.bazel.build/modules/aspect_rules_js/3.2.3/source.json": "fa3187962f5fdeee0c6226792904aaafb2cf585717a264fa4393a767bffb99fe", - "https://bcr.bazel.build/modules/aspect_rules_ts/3.9.2/MODULE.bazel": "feeb6c45b69c995eca3e5ca5872658c80df658022e01044eca00cf472bb89142", - "https://bcr.bazel.build/modules/aspect_rules_ts/3.9.2/source.json": "cf3075502f798f71a9c5707a7684eaf0b86da11ed440fd90ea34385dc297676a", + "https://bcr.bazel.build/modules/aspect_rules_js/3.4.0/MODULE.bazel": "88844ac411e1961f4574a92f3c5be5b20d1c6997778c6b88316c5c3b4b60e284", + "https://bcr.bazel.build/modules/aspect_rules_js/3.4.1/MODULE.bazel": "e46853c134b48bc84c9bb5c4a4c8ae792dcfd5277acac9d90c444d65fa43dd19", + "https://bcr.bazel.build/modules/aspect_rules_js/3.4.1/source.json": "b11f550b53d451800ebd0b4f38a048aea33fbfbd78fb5c77dca847f6b9b47a3b", + "https://bcr.bazel.build/modules/aspect_rules_ts/3.10.1/MODULE.bazel": "035205a7834b0ed9724ed3c4790e12d1c779ef2520d72496ab234e6307715e6c", + "https://bcr.bazel.build/modules/aspect_rules_ts/3.10.1/source.json": "d42b6560206adb5524caad2abc40ae0b5b34478dd38184a9f6ea4a5fed848cd6", "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.2.8/MODULE.bazel": "aa975a83e72bcaac62ee61ab12b788ea324a1d05c4aab28aadb202f647881679", "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.3/MODULE.bazel": "37c764292861c2f70314efa9846bb6dbb44fc0308903b3285da6528305450183", "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.4.2/MODULE.bazel": "f31aa84151d31e98cffd43eb7217ccff5ec52bdd5f2d10db8f053aeb23342eca", - "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.4.2/source.json": "d027d264e6b6e7fc421e38189f4374fcd14a67e0bd6e0e705de8c2185c3787e1", + "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.5.1/MODULE.bazel": "9d94e5c22c0019995c0a3216a6abb1717dc3f35acccf4e6267a55d6df38367aa", + "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.5.1/source.json": "dbe0a75bf1f741b18c7b72dd4d205fa5a45c8c135fee15f7fdd1a2b50ba0fdde", "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", @@ -39,6 +43,7 @@ "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a", "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58", "https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b", + "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65", "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", "https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e", @@ -51,9 +56,10 @@ "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", "https://bcr.bazel.build/modules/bazel_lib/3.0.0-rc.0/MODULE.bazel": "d6e00979a98ac14ada5e31c8794708b41434d461e7e7ca39b59b765e6d233b18", "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d", - "https://bcr.bazel.build/modules/bazel_lib/3.2.2/MODULE.bazel": "e2c890c8a515d6bca9c66d47718aa9e44b458fde64ec7204b8030bf2d349058c", - "https://bcr.bazel.build/modules/bazel_lib/3.5.0/MODULE.bazel": "1eba0ba67d418d99fb682c36147a9e1725abbc8fc25544be64702e32eda2be29", - "https://bcr.bazel.build/modules/bazel_lib/3.5.0/source.json": "98d49ec7e2b15298c990f09ec8cdea8d4788c2885497aa9cbb0a74e36e994df8", + "https://bcr.bazel.build/modules/bazel_lib/3.2.0/MODULE.bazel": "39b50d94b9be6bda507862254e20c263f9b950e3160112348d10a938be9ce2c2", + "https://bcr.bazel.build/modules/bazel_lib/3.7.0/MODULE.bazel": "d7c10ed67f0f7f1fda179db8f86c22642581bd614882e1a50545fbe069525173", + "https://bcr.bazel.build/modules/bazel_lib/3.7.2/MODULE.bazel": "cbc920fff00a55b2fd663d40850560a6fb17fd5ef3a9f3d6cbf0d32908dd8a40", + "https://bcr.bazel.build/modules/bazel_lib/3.7.2/source.json": "7a9c2b06a113c4962d329bb68befdb90d0447788a19aabaa32b476a0458e7d28", "https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8", "https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e", "https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686", @@ -69,7 +75,8 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7", - "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/source.json": "7ad77c1e8c1b84222d9b3f3cae016a76639435744c19330b0b37c0a3c9da7dc0", + "https://bcr.bazel.build/modules/bazel_skylib/1.9.2/MODULE.bazel": "8c51259b0f4481475586dbfede7591e57b75702e637f840f6138eec80c34b270", + "https://bcr.bazel.build/modules/bazel_skylib/1.9.2/source.json": "41cbde7546542dee2f26e3f10bf1c4ac57909943194b7ac48cc5238a01893aa8", "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84", "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8", "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8", @@ -82,8 +89,8 @@ "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f", "https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f", "https://bcr.bazel.build/modules/jq.bzl/0.4.0/MODULE.bazel": "a7b39b37589f2b0dad53fd6c1ccaabbdb290330caa920d7ef3e6aad068cd4ab2", - "https://bcr.bazel.build/modules/jq.bzl/0.6.1/MODULE.bazel": "f30c46e0a08a9f7566a8bf60a43d48abea960cd7f57b315b01e2762f1537eb52", - "https://bcr.bazel.build/modules/jq.bzl/0.6.1/source.json": "9ca9e2f90baa6a5bb0a49626ed9528554ec83165adf47b39792673ecc7feda22", + "https://bcr.bazel.build/modules/jq.bzl/0.6.2/MODULE.bazel": "e9c82f9b1e720d4ab0e232d32c05f0f4d0f92a8e8bb6a0da8f7cd27823b93e05", + "https://bcr.bazel.build/modules/jq.bzl/0.6.2/source.json": "e36f8ed173a6ca6e627f9d659ae504733d5a991859805661e4aacd34d8ae0639", "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", "https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d", "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", @@ -97,6 +104,7 @@ "https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615", "https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814", "https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d", + "https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc", "https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580", "https://bcr.bazel.build/modules/platforms/1.1.0/MODULE.bazel": "1c0c09f5bdcf4b3f924720d2478a3711cb39f4977019ca5988685e5b7e18b3d2", "https://bcr.bazel.build/modules/platforms/1.1.0/source.json": "fcf351c47596c939140ab0d333dfdd08ed1ea6ce33c2fe70c12493a301cf1344", @@ -128,8 +136,9 @@ "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8", "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4", - "https://bcr.bazel.build/modules/rules_cc/0.2.22/MODULE.bazel": "94df4328edef9e44d38de5e73b037cd348e75e7ae55f4e21bf07878c41a31ebb", - "https://bcr.bazel.build/modules/rules_cc/0.2.22/source.json": "b2d6d6f9c332ce269ad75b89c6f3168d809a66173c9040210fb9bcc733ab42fa", + "https://bcr.bazel.build/modules/rules_cc/0.2.20/MODULE.bazel": "f5c07bce5ddcb99be21a0812ff5aadb439e688b7449c6542152363b2fd859c1a", + "https://bcr.bazel.build/modules/rules_cc/0.2.25/MODULE.bazel": "4a3d4f3606d3b3190be495dbc497acca552807d1d5540661ef0d1658472882e3", + "https://bcr.bazel.build/modules/rules_cc/0.2.25/source.json": "8df28a9a97b878fe45f0a4f4adf019869dda51a60ee2f6e15a0c79656a25058d", "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", @@ -170,8 +179,8 @@ "https://bcr.bazel.build/modules/rules_nodejs/6.7.5/source.json": "d60ee5a76258b1c8f99545ed24172b44d43ba64ca1a2dfc04371ef203df19fdf", "https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc", "https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff", - "https://bcr.bazel.build/modules/rules_pkg/1.2.0/MODULE.bazel": "c7db3c2b407e673c7a39e3625dc05dc9f12d6682cbd82a3a5924a13b491eda7e", - "https://bcr.bazel.build/modules/rules_pkg/1.2.0/source.json": "9062e00845bf91a4247465d371baa837adf9b6ff44c542f73ba084f07667e1dc", + "https://bcr.bazel.build/modules/rules_pkg/1.3.0/MODULE.bazel": "ae0bdefbacc990c91f843206c90cf0f4be620639a5bf22119043599ba86d51a3", + "https://bcr.bazel.build/modules/rules_pkg/1.3.0/source.json": "58ae84c545141762f7c434c0ce78bfb55ef0be08d84863e7f0503fff135fe4e2", "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", "https://bcr.bazel.build/modules/rules_proto/6.0.0/MODULE.bazel": "b531d7f09f58dce456cd61b4579ce8c86b38544da75184eadaf0a7cb7966453f", @@ -185,7 +194,6 @@ "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58", "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7", - "https://bcr.bazel.build/modules/rules_python/1.0.0/MODULE.bazel": "898a3d999c22caa585eb062b600f88654bf92efb204fa346fb55f6f8edffca43", "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8", "https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32", "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", @@ -201,17 +209,15 @@ "https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5", "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", "https://bcr.bazel.build/modules/tar.bzl/0.10.4/MODULE.bazel": "e8f9ff79199e8d9eaad7f1b0a77ad74b30bb82d794b87d8ca942bead5de83ae9", - "https://bcr.bazel.build/modules/tar.bzl/0.10.7/MODULE.bazel": "e06d0072c8adef7b9efbeec951d7e6b4b7e6bfa5845c3d8f402289c7b5d6331c", - "https://bcr.bazel.build/modules/tar.bzl/0.10.7/source.json": "c660155f239fcfadfb85f0b9ff304b95390632c15e3f6cb718cd3e08f2bb5c86", + "https://bcr.bazel.build/modules/tar.bzl/0.10.9/MODULE.bazel": "4636f5f6a7c34d3d0653634ed95a34560426d548c5301c6c5924c885ef737b46", + "https://bcr.bazel.build/modules/tar.bzl/0.10.9/source.json": "30a5cf0a8281adce53ab1c5a4291e88b2c161e2df1feb6cba4ee50556f1748d7", "https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468", "https://bcr.bazel.build/modules/tar.bzl/0.5.1/MODULE.bazel": "7c2eb3dcfc53b0f3d6f9acdfd911ca803eaf92aadf54f8ca6e4c1f3aee288351", - "https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", "https://bcr.bazel.build/modules/yq.bzl/0.1.1/MODULE.bazel": "9039681f9bcb8958ee2c87ffc74bdafba9f4369096a2b5634b88abc0eaefa072", - "https://bcr.bazel.build/modules/yq.bzl/0.3.2/MODULE.bazel": "0384efa70e8033d842ea73aa4b7199fa099709e236a7264345c03937166670b6", "https://bcr.bazel.build/modules/yq.bzl/0.3.4/MODULE.bazel": "d3a270662f5d766cd7229732d65a5a5bc485240c3007343dd279edfb60c9ae27", - "https://bcr.bazel.build/modules/yq.bzl/0.3.6/MODULE.bazel": "985c2a0cb4ad9994bb0e33cc7fae931c91105eeefe3faa355b8f4c258d0607c0", - "https://bcr.bazel.build/modules/yq.bzl/0.3.6/source.json": "678aaf6e291164f3cd761bb3e872e8a151248f413dbb63c5524a50b82a5bc890", + "https://bcr.bazel.build/modules/yq.bzl/0.4.0/MODULE.bazel": "1e4b142e391b62c95eb637fc0e944c17f3ed5276cdc9d7033df72a2f96a2a457", + "https://bcr.bazel.build/modules/yq.bzl/0.4.0/source.json": "e5a3b94fe7ab720818ac2d8150cb19b009daf48c69a72eaab9a4852efb75331d", "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", "https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", @@ -222,11 +228,30 @@ "moduleExtensions": { "@@aspect_rules_esbuild+//esbuild:extensions.bzl%esbuild": { "general": { - "bzlTransitiveDigest": "DE8ssop85SA4kEHMbyc2ajgEW3TNsQFbT4L6IfuwTZo=", - "usagesDigest": "LSQ+zZp7JNgnBONTxxXnwGr4NTh2qtQYk7qwXXz5qWo=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "bzlTransitiveDigest": "bn5qyJpbHTM21Kl9gvkARvwxKe1bH7JC951YjIa5vuk=", + "usagesDigest": "ZqBbSnzAi8ZyBL3PXnAGtDMIdC2pcuieJWMY3/94lvA=", + "recordedInputs": [ + "REPO_MAPPING:aspect_rules_esbuild+,aspect_rules_js aspect_rules_js+", + "REPO_MAPPING:aspect_rules_esbuild+,aspect_tools_telemetry_report aspect_tools_telemetry++telemetry+aspect_tools_telemetry_report", + "REPO_MAPPING:aspect_rules_esbuild+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:aspect_rules_js+,aspect_rules_js aspect_rules_js+", + "REPO_MAPPING:aspect_rules_js+,aspect_tools_telemetry_report aspect_tools_telemetry++telemetry+aspect_tools_telemetry_report", + "REPO_MAPPING:aspect_rules_js+,bazel_features bazel_features+", + "REPO_MAPPING:aspect_rules_js+,bazel_lib bazel_lib+", + "REPO_MAPPING:aspect_rules_js+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:aspect_rules_js+,bazel_tools bazel_tools", + "REPO_MAPPING:aspect_rules_js+,protobuf protobuf+", + "REPO_MAPPING:aspect_rules_js+,tar.bzl tar.bzl+", + "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals", + "REPO_MAPPING:bazel_features+,bazel_features_version bazel_features++version_extension+bazel_features_version", + "REPO_MAPPING:bazel_lib+,bazel_lib bazel_lib+", + "REPO_MAPPING:bazel_lib+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:bazel_lib+,bazel_tools bazel_tools", + "REPO_MAPPING:protobuf+,proto_bazel_features bazel_features+", + "REPO_MAPPING:tar.bzl+,bazel_lib bazel_lib+", + "REPO_MAPPING:tar.bzl+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:tar.bzl+,tar.bzl tar.bzl+" + ], "generatedRepoSpecs": { "esbuild_darwin-x64": { "repoRuleId": "@@aspect_rules_esbuild+//esbuild:repositories.bzl%esbuild_repositories", @@ -330,130 +355,28 @@ "exclude_package_contents_presets": [] } } - }, - "recordedRepoMappingEntries": [ - [ - "aspect_rules_esbuild+", - "aspect_rules_js", - "aspect_rules_js+" - ], - [ - "aspect_rules_esbuild+", - "aspect_tools_telemetry_report", - "aspect_tools_telemetry++telemetry+aspect_tools_telemetry_report" - ], - [ - "aspect_rules_esbuild+", - "bazel_skylib", - "bazel_skylib+" - ], - [ - "aspect_rules_js+", - "aspect_rules_js", - "aspect_rules_js+" - ], - [ - "aspect_rules_js+", - "aspect_tools_telemetry_report", - "aspect_tools_telemetry++telemetry+aspect_tools_telemetry_report" - ], - [ - "aspect_rules_js+", - "bazel_features", - "bazel_features+" - ], - [ - "aspect_rules_js+", - "bazel_lib", - "bazel_lib+" - ], - [ - "aspect_rules_js+", - "bazel_skylib", - "bazel_skylib+" - ], - [ - "aspect_rules_js+", - "bazel_tools", - "bazel_tools" - ], - [ - "aspect_rules_js+", - "protobuf", - "protobuf+" - ], - [ - "aspect_rules_js+", - "tar.bzl", - "tar.bzl+" - ], - [ - "bazel_features+", - "bazel_features_globals", - "bazel_features++version_extension+bazel_features_globals" - ], - [ - "bazel_features+", - "bazel_features_version", - "bazel_features++version_extension+bazel_features_version" - ], - [ - "bazel_lib+", - "bazel_lib", - "bazel_lib+" - ], - [ - "bazel_lib+", - "bazel_skylib", - "bazel_skylib+" - ], - [ - "bazel_lib+", - "bazel_tools", - "bazel_tools" - ], - [ - "protobuf+", - "proto_bazel_features", - "bazel_features+" - ], - [ - "tar.bzl+", - "bazel_lib", - "bazel_lib+" - ], - [ - "tar.bzl+", - "bazel_skylib", - "bazel_skylib+" - ], - [ - "tar.bzl+", - "tar.bzl", - "tar.bzl+" - ] - ] + } } }, "@@aspect_tools_telemetry+//:extension.bzl%telemetry": { "general": { - "bzlTransitiveDigest": "4w9RM0xjdKo1crk5zL20a/TuhqO0P1z1LsuXDneBXD4=", - "usagesDigest": "hqK7Zzp+y5jlPhecF8Z77KSrR9zpTS+H88Epy3pRc/s=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": { - "ASPECT_TOOLS_TELEMETRY_TEST": null - }, + "bzlTransitiveDigest": "zFgbaCUuPzH6bIE7CBHBVCqjWSJ78/WNXisdzXaKSnU=", + "usagesDigest": "B4C1UNcAOoRiMjmkXh3VFW7fY2YULy8OZA7RjfEM2UQ=", + "recordedInputs": [ + "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+", + "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+", + "ENV:ASPECT_TOOLS_TELEMETRY_TEST \\0" + ], "generatedRepoSpecs": { "aspect_tools_telemetry_report": { "repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository", "attributes": { "deps": { - "aspect_rules_js": "3.2.3", - "aspect_rules_ts": "3.9.2", - "aspect_rules_esbuild": "0.26.0", + "aspect_rules_js": "3.4.1", + "aspect_rules_ts": "3.10.1", + "aspect_rules_esbuild": "0.27.1", "aspect_rules_jasmine": "2.0.4", - "aspect_tools_telemetry": "0.4.2" + "aspect_tools_telemetry": "0.5.1" }, "last_notice": 0 } @@ -462,30 +385,17 @@ "moduleExtensionMetadata": { "useAllRepos": "NO", "reproducible": false - }, - "recordedRepoMappingEntries": [ - [ - "aspect_tools_telemetry+", - "bazel_lib", - "bazel_lib+" - ], - [ - "aspect_tools_telemetry+", - "bazel_skylib", - "bazel_skylib+" - ] - ] + } } }, "@@pybind11_bazel+//:python_configure.bzl%extension": { "general": { "bzlTransitiveDigest": "VhEtmxw1yzb9rBZVsKTdti7p+nDM/Fv1p9TmKdO45+Q=", "usagesDigest": "fycyB39YnXIJkfWCIXLUKJMZzANcuLy9ZE73hRucjFk=", - "recordedFileInputs": { - "@@pybind11_bazel+//MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e" - }, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:pybind11_bazel+,bazel_tools bazel_tools", + "FILE:@@pybind11_bazel+//MODULE.bazel 88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e" + ], "generatedRepoSpecs": { "local_config_python": { "repoRuleId": "@@pybind11_bazel+//:python_configure.bzl%python_configure", @@ -501,23 +411,14 @@ ] } } - }, - "recordedRepoMappingEntries": [ - [ - "pybind11_bazel+", - "bazel_tools", - "bazel_tools" - ] - ] + } } }, "@@rules_angular+//setup:extensions.bzl%rules_angular": { "general": { "bzlTransitiveDigest": "aS7Uud1IzoU7PPLzH3s6IfFS4b2fa0SRWDi2/fS4bQU=", "usagesDigest": "NK6UKnTWfOFl5i+flt6LxviEbMHgJEH+JyjGkTh3Vjo=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [], "generatedRepoSpecs": { "angular_cli_rules_angular_configurable_deps": { "repoRuleId": "@@rules_angular+//setup:repositories.bzl%configurable_deps_repo", @@ -533,24 +434,21 @@ "typescript": "@@//:node_modules/typescript" } } - }, - "recordedRepoMappingEntries": [] + } } }, "@@rules_browsers+//browsers:extensions.bzl%browsers": { "general": { - "bzlTransitiveDigest": "3W9m9j39szqkOoSVVqamCaSwCCw7x5Wq1kcYTwYBNOc=", + "bzlTransitiveDigest": "jwGo+QiVY+i/AnJLUkEJ+vaplFYfygil2SJt8C7kM5o=", "usagesDigest": "FmXYJVoVJlnfUU8x8gObSvu4qWcco/9Faw61aC/wBF0=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [], "generatedRepoSpecs": { "rules_browsers_chrome_linux": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "13d6638183ccab406ee16e28730826bd3ea57fe564a741bc935401221dfec64f", + "sha256": "11d48f3e2ee11fc2a53a230eb364b2becc44c1b67a7aa4683edb7cb8a950b6ab", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/linux64/chrome-headless-shell-linux64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/linux64/chrome-headless-shell-linux64.zip" ], "named_files": { "CHROME-HEADLESS-SHELL": "chrome-headless-shell-linux64/chrome-headless-shell" @@ -566,9 +464,9 @@ "rules_browsers_chrome_mac": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "b50ee6ddb3bf30c91f3094fa41cc8e8de60e317c63ef058db2375346cde78d98", + "sha256": "42c60eb1793fe76aabf19664b03c299170e5de53baa23ae72578f063dc9b2614", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/mac-x64/chrome-headless-shell-mac-x64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/mac-x64/chrome-headless-shell-mac-x64.zip" ], "named_files": { "CHROME-HEADLESS-SHELL": "chrome-headless-shell-mac-x64/chrome-headless-shell" @@ -584,9 +482,9 @@ "rules_browsers_chrome_mac_arm": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "49859c1d3b0d29a82c58765e9e5fe21efa0bf7c03a86fc91eb5f3f94f34c45b6", + "sha256": "4afc1dbaeea03ad545b434c0f9eba50af326923fc265bc25927b3b4c7d9ca377", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/mac-arm64/chrome-headless-shell-mac-arm64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/mac-arm64/chrome-headless-shell-mac-arm64.zip" ], "named_files": { "CHROME-HEADLESS-SHELL": "chrome-headless-shell-mac-arm64/chrome-headless-shell" @@ -602,9 +500,9 @@ "rules_browsers_chrome_win64": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "d161d88a999effd212ce87df2ff710be1a25a495cc428d4c3029feccd02dafe9", + "sha256": "9de8381d4ca9c9f0471fd1cce15e44146328c3e11f55fec43cd667ae1a5bd482", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/win64/chrome-headless-shell-win64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/win64/chrome-headless-shell-win64.zip" ], "named_files": { "CHROME-HEADLESS-SHELL": "chrome-headless-shell-win64/chrome-headless-shell.exe" @@ -620,9 +518,9 @@ "rules_browsers_chromedriver_linux": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "7bb2eedf7030523ba617704bf6c3e472b5974d048d7fa6ac6a764904556c6f36", + "sha256": "29be812bf4392619d06dfb5716d2bf9d72a278304670a594e3977d4c2cce7502", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/linux64/chromedriver-linux64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/linux64/chromedriver-linux64.zip" ], "named_files": { "CHROMEDRIVER": "chromedriver-linux64/chromedriver" @@ -636,9 +534,9 @@ "rules_browsers_chromedriver_mac": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "5c1fffef90ecea2bb0c12c5a9c0549116fc7acda13cce15881da4aa8b74e6794", + "sha256": "d4dc141aadd9525e67c92fc78ec65e2926226c436eca7d90e39cdc813726513d", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/mac-x64/chromedriver-mac-x64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/mac-x64/chromedriver-mac-x64.zip" ], "named_files": { "CHROMEDRIVER": "chromedriver-mac-x64/chromedriver" @@ -652,9 +550,9 @@ "rules_browsers_chromedriver_mac_arm": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "a33d54c99a50fef25cbedb7c3d3cdd838dd784556138648bcc5da5814109a306", + "sha256": "984056c6d3dd189569cc89affd28564a9de949d58297279fc23a045c8fc53080", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/mac-arm64/chromedriver-mac-arm64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/mac-arm64/chromedriver-mac-arm64.zip" ], "named_files": { "CHROMEDRIVER": "chromedriver-mac-arm64/chromedriver" @@ -668,9 +566,9 @@ "rules_browsers_chromedriver_win64": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "2673d6a87dabd50c516d4d69b4daf742ff6169877c04bb226971503012262d8c", + "sha256": "a5bcbd013b64e6a15acc84c164fe974d0f1a58d4e061fba79d24b31cced64d88", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/win64/chromedriver-win64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/win64/chromedriver-win64.zip" ], "named_files": { "CHROMEDRIVER": "chromedriver-win64/chromedriver.exe" @@ -684,9 +582,9 @@ "rules_browsers_firefox_linux": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "748704b06ffaabc8bd2b3351c93c244db4e29bfe21af6ecb4d6c124c7bd4a234", + "sha256": "1d44cd02351c307c3e19061ea2a4d18a30f236e6be862b94f2282564afdb0167", "urls": [ - "https://archive.mozilla.org/pub/firefox/releases/152.0/linux-x86_64/en-US/firefox-152.0.tar.xz" + "https://archive.mozilla.org/pub/firefox/releases/156.0/linux-x86_64/en-US/firefox-156.0.tar.xz" ], "named_files": { "FIREFOX": "firefox/firefox" @@ -700,9 +598,9 @@ "rules_browsers_firefox_mac": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "b8a46188850d2fb32f16ad3c0829b08cd689bc714b8ee18fd9b09bb60629004d", + "sha256": "fecc46103039ca4a77ecb2b2ef7b03892f922e13db0782f8570c512b2e458515", "urls": [ - "https://archive.mozilla.org/pub/firefox/releases/152.0/mac/en-US/Firefox%20152.0.dmg" + "https://archive.mozilla.org/pub/firefox/releases/156.0/mac/en-US/Firefox%20156.0.dmg" ], "named_files": { "FIREFOX": "Firefox.app/Contents/MacOS/firefox" @@ -716,9 +614,9 @@ "rules_browsers_firefox_mac_arm": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "b8a46188850d2fb32f16ad3c0829b08cd689bc714b8ee18fd9b09bb60629004d", + "sha256": "fecc46103039ca4a77ecb2b2ef7b03892f922e13db0782f8570c512b2e458515", "urls": [ - "https://archive.mozilla.org/pub/firefox/releases/152.0/mac/en-US/Firefox%20152.0.dmg" + "https://archive.mozilla.org/pub/firefox/releases/156.0/mac/en-US/Firefox%20156.0.dmg" ], "named_files": { "FIREFOX": "Firefox.app/Contents/MacOS/firefox" @@ -732,9 +630,9 @@ "rules_browsers_firefox_win64": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "3c426623ff83cb014402607c7ec99fea5b39eec2e44a07f639680c393ff9784e", + "sha256": "86a027237f7408e8a4d18637c57d487455a704cbb2466a32d0d14c2207d2699a", "urls": [ - "https://archive.mozilla.org/pub/firefox/releases/152.0/win64/en-US/Firefox%20Setup%20152.0.exe" + "https://archive.mozilla.org/pub/firefox/releases/156.0/win64/en-US/Firefox%20Setup%20156.0.exe" ], "named_files": { "FIREFOX": "core/firefox.exe" @@ -745,17 +643,16 @@ ] } } - }, - "recordedRepoMappingEntries": [] + } } }, "@@rules_fuzzing+//fuzzing/private:extensions.bzl%non_module_dependencies": { "general": { "bzlTransitiveDigest": "CYUiFDCnL2VGx3uotIu/VuGabgObnZra2zzRUJCX0sU=", "usagesDigest": "wy6ISK6UOcBEjj/mvJ/S3WeXoO67X+1llb9yPyFtPgc=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:rules_fuzzing+,bazel_tools bazel_tools" + ], "generatedRepoSpecs": { "platforms": { "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", @@ -822,23 +719,16 @@ "url": "https://repo1.maven.org/maven2/com/code-intelligence/jazzer-api/0.20.1/jazzer-api-0.20.1.jar" } } - }, - "recordedRepoMappingEntries": [ - [ - "rules_fuzzing+", - "bazel_tools", - "bazel_tools" - ] - ] + } } }, "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { "general": { "bzlTransitiveDigest": "03Qju4tW0vE+0RBuZGuV2A4Hx6AiSkdNahYvworx2aM=", "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:rules_kotlin+,bazel_tools bazel_tools" + ], "generatedRepoSpecs": { "com_github_jetbrains_kotlin_git": { "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository", @@ -886,23 +776,31 @@ ] } } - }, - "recordedRepoMappingEntries": [ - [ - "rules_kotlin+", - "bazel_tools", - "bazel_tools" - ] - ] + } } }, "@@rules_python+//python/extensions:config.bzl%config": { "general": { "bzlTransitiveDigest": "1eE/aNv0So57EZMzcxgDSCFkzlydiaC4TpHowQfrcBk=", "usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", + "REPO_MAPPING:rules_python+,pypi__build rules_python++config+pypi__build", + "REPO_MAPPING:rules_python+,pypi__click rules_python++config+pypi__click", + "REPO_MAPPING:rules_python+,pypi__colorama rules_python++config+pypi__colorama", + "REPO_MAPPING:rules_python+,pypi__importlib_metadata rules_python++config+pypi__importlib_metadata", + "REPO_MAPPING:rules_python+,pypi__installer rules_python++config+pypi__installer", + "REPO_MAPPING:rules_python+,pypi__more_itertools rules_python++config+pypi__more_itertools", + "REPO_MAPPING:rules_python+,pypi__packaging rules_python++config+pypi__packaging", + "REPO_MAPPING:rules_python+,pypi__pep517 rules_python++config+pypi__pep517", + "REPO_MAPPING:rules_python+,pypi__pip rules_python++config+pypi__pip", + "REPO_MAPPING:rules_python+,pypi__pip_tools rules_python++config+pypi__pip_tools", + "REPO_MAPPING:rules_python+,pypi__pyproject_hooks rules_python++config+pypi__pyproject_hooks", + "REPO_MAPPING:rules_python+,pypi__setuptools rules_python++config+pypi__setuptools", + "REPO_MAPPING:rules_python+,pypi__tomli rules_python++config+pypi__tomli", + "REPO_MAPPING:rules_python+,pypi__wheel rules_python++config+pypi__wheel", + "REPO_MAPPING:rules_python+,pypi__zipp rules_python++config+pypi__zipp" + ], "generatedRepoSpecs": { "rules_python_internal": { "repoRuleId": "@@rules_python+//python/private:internal_config_repo.bzl%internal_config_repo", @@ -1046,98 +944,17 @@ "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" } } - }, - "recordedRepoMappingEntries": [ - [ - "rules_python+", - "bazel_tools", - "bazel_tools" - ], - [ - "rules_python+", - "pypi__build", - "rules_python++config+pypi__build" - ], - [ - "rules_python+", - "pypi__click", - "rules_python++config+pypi__click" - ], - [ - "rules_python+", - "pypi__colorama", - "rules_python++config+pypi__colorama" - ], - [ - "rules_python+", - "pypi__importlib_metadata", - "rules_python++config+pypi__importlib_metadata" - ], - [ - "rules_python+", - "pypi__installer", - "rules_python++config+pypi__installer" - ], - [ - "rules_python+", - "pypi__more_itertools", - "rules_python++config+pypi__more_itertools" - ], - [ - "rules_python+", - "pypi__packaging", - "rules_python++config+pypi__packaging" - ], - [ - "rules_python+", - "pypi__pep517", - "rules_python++config+pypi__pep517" - ], - [ - "rules_python+", - "pypi__pip", - "rules_python++config+pypi__pip" - ], - [ - "rules_python+", - "pypi__pip_tools", - "rules_python++config+pypi__pip_tools" - ], - [ - "rules_python+", - "pypi__pyproject_hooks", - "rules_python++config+pypi__pyproject_hooks" - ], - [ - "rules_python+", - "pypi__setuptools", - "rules_python++config+pypi__setuptools" - ], - [ - "rules_python+", - "pypi__tomli", - "rules_python++config+pypi__tomli" - ], - [ - "rules_python+", - "pypi__wheel", - "rules_python++config+pypi__wheel" - ], - [ - "rules_python+", - "pypi__zipp", - "rules_python++config+pypi__zipp" - ] - ] + } } }, "@@rules_python+//python/uv:uv.bzl%uv": { "general": { "bzlTransitiveDigest": "ijW9KS7qsIY+yBVvJ+Nr1mzwQox09j13DnE3iIwaeTM=", "usagesDigest": "H8dQoNZcoqP+Mu0tHZTi4KHATzvNkM5ePuEqoQdklIU=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", + "REPO_MAPPING:rules_python+,platforms platforms" + ], "generatedRepoSpecs": { "uv": { "repoRuleId": "@@rules_python+//python/uv/private:uv_toolchains_repo.bzl%uv_toolchains_repo", @@ -1157,28 +974,16 @@ "toolchain_target_settings": {} } } - }, - "recordedRepoMappingEntries": [ - [ - "rules_python+", - "bazel_tools", - "bazel_tools" - ], - [ - "rules_python+", - "platforms", - "platforms" - ] - ] + } } }, "@@yq.bzl+//yq:extensions.bzl%yq": { "general": { - "bzlTransitiveDigest": "UfFMy8CWK4/dVo/tfaSAIYUiDGNAPes5eRllx9O9Q9Q=", - "usagesDigest": "Sllnnruao+9gE+ivwtmfqL78yZPGXm4HVMOBoVEIW0U=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "bzlTransitiveDigest": "lH0oIGWeh0sI6/jlDMTxYo34YKkvNfvwdiMIhfM532I=", + "usagesDigest": "9q9u3esoCq5ywKUTsbBWS8sgD0xhtCRDQhZ6Dsn/qvE=", + "recordedInputs": [ + "REPO_MAPPING:yq.bzl+,bazel_lib bazel_lib+" + ], "generatedRepoSpecs": { "yq_darwin_amd64": { "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", @@ -1248,15 +1053,19 @@ "attributes": { "user_repository_name": "yq" } + }, + "yq": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:toolchain.bzl%yq_host_alias_repo", + "attributes": {} } - }, - "recordedRepoMappingEntries": [] + } } } }, "facts": { "@@aspect_tools_telemetry+//:extension.bzl%telemetry": { - "notice_version": "1" + "notice_version": "2" } - } + }, + "factsVersions": {} } diff --git a/constants.bzl b/constants.bzl index 6987637a4442..143ee7243735 100644 --- a/constants.bzl +++ b/constants.bzl @@ -3,10 +3,10 @@ RELEASE_ENGINES_NODE = "^22.22.3 || ^24.15.0 || >=26.0.0" RELEASE_ENGINES_NPM = "^6.11.0 || ^7.5.6 || >=8.0.0" RELEASE_ENGINES_YARN = ">= 1.13.0" -NG_PACKAGR_VERSION = "^22.1.0-next.0" -ANGULAR_FW_VERSION = "^22.1.0-next.0" -ANGULAR_FW_PEER_DEP = "^22.0.0 || ^22.1.0-next.0" -NG_PACKAGR_PEER_DEP = "^22.0.0 || ^22.1.0-next.0" +NG_PACKAGR_VERSION = "^22.2.0-next.0" +ANGULAR_FW_VERSION = "^22.2.0-next.0" +ANGULAR_FW_PEER_DEP = "^22.0.0 || ^22.2.0-next.0" +NG_PACKAGR_PEER_DEP = "^22.0.0 || ^22.2.0-next.0" # Baseline widely-available date in `YYYY-MM-DD` format which defines Angular's # browser support. This date serves as the source of truth for the Angular CLI's diff --git a/goldens/public-api/angular/build/index.api.md b/goldens/public-api/angular/build/index.api.md index 3c0b9bd67c53..c9d83bc6fd9b 100644 --- a/goldens/public-api/angular/build/index.api.md +++ b/goldens/public-api/angular/build/index.api.md @@ -243,6 +243,7 @@ export type UnitTestBuilderOptions = { runner?: Runner; runnerConfig?: RunnerConfig; setupFiles?: string[]; + splitting?: boolean; tsConfig?: string; ui?: boolean; watch?: boolean; diff --git a/goldens/public-api/angular_devkit/architect/node/index.api.md b/goldens/public-api/angular_devkit/architect/node/index.api.md new file mode 100644 index 000000000000..059423576f71 --- /dev/null +++ b/goldens/public-api/angular_devkit/architect/node/index.api.md @@ -0,0 +1,73 @@ +## API Report File for "@angular-devkit/architect_node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BaseException } from '@angular-devkit/core'; +import { json } from '@angular-devkit/core'; +import { JsonObject } from '@angular-devkit/core'; +import { JsonValue } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; +import { Observable } from 'rxjs'; +import { Observer } from 'rxjs'; +import { schema } from '@angular-devkit/core'; +import { workspaces } from '@angular-devkit/core'; + +declare namespace jobs { + export { + NodeModuleJobRegistry + } +} +export { jobs } + +// @public (undocumented) +class NodeModuleJobRegistry implements jobs_2.Registry { + get(name: jobs_2.JobName): Observable | null>; + // (undocumented) + protected _resolve(name: string): string | null; +} + +// @public (undocumented) +export type NodeModulesBuilderInfo = BuilderInfo & { + import: string; +}; + +// @public (undocumented) +export interface WorkspaceHost { + // (undocumented) + getBuilderName(project: string, target: string): Promise; + // (undocumented) + getDefaultConfigurationName(project: string, target: string): Promise; + // (undocumented) + getMetadata(project: string): Promise; + // (undocumented) + getOptions(project: string, target: string, configuration?: string): Promise; + // (undocumented) + hasTarget(project: string, target: string): Promise; +} + +// @public (undocumented) +export class WorkspaceNodeModulesArchitectHost implements ArchitectHost { + constructor(workspaceHost: WorkspaceHost, _root: string); + constructor(workspace: workspaces.WorkspaceDefinition, _root: string); + // (undocumented) + getBuilderNameForTarget(target: Target_2): Promise; + // (undocumented) + getCurrentDirectory(): Promise; + // (undocumented) + getOptionsForTarget(target: Target_2): Promise; + // (undocumented) + getProjectMetadata(target: Target_2 | string): Promise; + // (undocumented) + getWorkspaceRoot(): Promise; + // (undocumented) + loadBuilder(info: NodeModulesBuilderInfo): Promise; + resolveBuilder(builderStr: string, basePath?: string, seenBuilders?: Set): Promise; + // (undocumented) + protected _root: string; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/architect/testing/index.api.md b/goldens/public-api/angular_devkit/architect/testing/index.api.md new file mode 100644 index 000000000000..23cc6fe8cd7a --- /dev/null +++ b/goldens/public-api/angular_devkit/architect/testing/index.api.md @@ -0,0 +1,76 @@ +## API Report File for "@angular-devkit/architect_testing" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { json } from '@angular-devkit/core'; +import { JsonObject } from '@angular-devkit/core'; +import { JsonValue } from '@angular-devkit/core'; +import { NodeJsSyncHost } from '@angular-devkit/core/node'; +import { Observable } from 'rxjs'; +import { Observer } from 'rxjs'; +import { Path } from '@angular-devkit/core'; +import { PathFragment } from '@angular-devkit/core'; +import { schema } from '@angular-devkit/core'; +import { Stats } from 'node:fs'; +import { virtualFs } from '@angular-devkit/core'; + +// @public (undocumented) +export class TestingArchitectHost implements ArchitectHost { + constructor(workspaceRoot?: string, currentDirectory?: string, _backendHost?: ArchitectHost | null); + // (undocumented) + addBuilder(builderName: string, builder: Builder, description?: string, optionSchema?: json.schema.JsonSchema): void; + // (undocumented) + addBuilderFromPackage(packageName: string): Promise; + // (undocumented) + addTarget(target: Target, builderName: string, options?: json.JsonObject): void; + // (undocumented) + currentDirectory: string; + // (undocumented) + getBuilderNameForTarget(target: Target): Promise; + // (undocumented) + getCurrentDirectory(): Promise; + // (undocumented) + getOptionsForTarget(target: Target): Promise; + // (undocumented) + getProjectMetadata(target: Target | string): Promise; + // (undocumented) + getWorkspaceRoot(): Promise; + // (undocumented) + loadBuilder(info: BuilderInfo): Promise; + resolveBuilder(builderName: string): Promise; + // (undocumented) + workspaceRoot: string; +} + +// @public @deprecated (undocumented) +export class TestProjectHost extends NodeJsSyncHost { + constructor(_templateRoot: Path); + // (undocumented) + appendToFile(path: string, str: string): void; + // (undocumented) + copyFile(from: string, to: string): void; + // (undocumented) + fileMatchExists(dir: string, regex: RegExp): PathFragment | undefined; + // (undocumented) + initialize(): Observable; + // (undocumented) + replaceInFile(path: string, match: RegExp | string, replacement: string): void; + // (undocumented) + restore(): Observable; + // (undocumented) + root(): Path; + // (undocumented) + scopedSync(): virtualFs.SyncDelegateHost; + // (undocumented) + protected _templateRoot: Path; + // (undocumented) + writeMultipleFiles(files: { + [path: string]: string | ArrayBufferLike | Buffer; + }): void; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/build_webpack/index.api.md b/goldens/public-api/angular_devkit/build_webpack/index.api.md index abe0725a36fe..3e7ebf07873b 100644 --- a/goldens/public-api/angular_devkit/build_webpack/index.api.md +++ b/goldens/public-api/angular_devkit/build_webpack/index.api.md @@ -6,6 +6,7 @@ import { BuilderContext } from '@angular-devkit/architect'; import { BuilderOutput } from '@angular-devkit/architect'; +import type { Configuration } from 'webpack-dev-server'; import { Observable } from 'rxjs'; import type webpack from 'webpack'; import type WebpackDevServer from 'webpack-dev-server'; @@ -50,7 +51,7 @@ export function runWebpack(config: webpack.Configuration, context: BuilderContex // @public @deprecated (undocumented) export function runWebpackDevServer(config: webpack.Configuration, context: BuilderContext, options?: { shouldProvideStats?: boolean; - devServerConfig?: WebpackDevServer.Configuration; + devServerConfig?: Configuration; logging?: WebpackLoggingCallback; webpackFactory?: WebpackFactory; webpackDevServerFactory?: WebpackDevServerFactory; diff --git a/modules/testing/builder/package.json b/modules/testing/builder/package.json index 9aede9dc7d10..d76148352027 100644 --- a/modules/testing/builder/package.json +++ b/modules/testing/builder/package.json @@ -4,12 +4,12 @@ "@angular-devkit/build-angular": "workspace:*", "@angular-devkit/core": "workspace:*", "@angular/ssr": "workspace:*", - "@vitest/coverage-v8": "4.1.10", + "@vitest/coverage-v8": "5.0.1", "browser-sync": "3.0.4", "istanbul-lib-instrument": "6.0.3", - "jsdom": "29.1.1", - "ng-packagr": "22.1.0-next.3", + "jsdom": "30.0.1", + "ng-packagr": "22.2.0-rc.0", "rxjs": "7.8.2", - "vitest": "4.1.10" + "vitest": "5.0.1" } } diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/package.json b/modules/testing/builder/projects/hello-world-lib/projects/lib/package.json index 0ec9802c2c70..c8105b2c16b4 100644 --- a/modules/testing/builder/projects/hello-world-lib/projects/lib/package.json +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/package.json @@ -2,7 +2,7 @@ "name": "lib", "version": "0.0.1", "peerDependencies": { - "@angular/common": "^22.1.0-next", - "@angular/core": "^22.1.0-next" + "@angular/common": "^22.2.0-next", + "@angular/core": "^22.2.0-next" } } \ No newline at end of file diff --git a/modules/testing/builder/src/builder-harness.ts b/modules/testing/builder/src/builder-harness.ts index 67b5f760d148..570939cecd7f 100644 --- a/modules/testing/builder/src/builder-harness.ts +++ b/modules/testing/builder/src/builder-harness.ts @@ -111,7 +111,7 @@ export class BuilderHarness { } } - private resolvePath(path: string): string { + resolvePath(path: string): string { return join(getSystemPath(this.host.root()), path); } diff --git a/package.json b/package.json index f67f33227c76..3cf313dbfe2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@angular/devkit-repo", - "version": "22.1.0-next.4", + "version": "22.3.0-next.0", "private": true, "description": "Software Development Kit for Angular", "keywords": [ @@ -14,11 +14,12 @@ "admin": "node --no-warnings=ExperimentalWarning --experimental-transform-types ./scripts/devkit-admin.mts", "bazel": "bazelisk", "test": "bazel test //packages/...", - "build": "pnpm -s admin build", + "build": "pnpm --silent admin build", + "benchmark": "node --no-warnings=ExperimentalWarning --experimental-transform-types --expose-gc ./scripts/devkit-admin.mts benchmark", "build-schema": "bazel build //... --build_tag_filters schema --symlink_prefix dist-schema/", "lint": "eslint --cache --max-warnings=0", - "templates": "pnpm -s admin templates", - "validate": "pnpm -s admin validate", + "templates": "pnpm --silent admin templates", + "validate": "pnpm --silent admin validate", "postinstall": "husky", "ts-circular-deps": "ng-dev ts-circular-deps --config ./scripts/circular-deps-test.conf.mjs", "check-tooling-setup": "tsc --project .ng-dev/tsconfig.json", @@ -28,12 +29,12 @@ "type": "git", "url": "git+https://github.com/angular/angular-cli.git" }, - "packageManager": "pnpm@11.15.1", + "packageManager": "pnpm@12.4.2", "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0.0", "npm": "Please use pnpm instead of NPM to install dependencies", "yarn": "Please use pnpm instead of Yarn to install dependencies", - "pnpm": "11.15.1" + "pnpm": "12.4.2" }, "author": "Angular Authors", "license": "MIT", @@ -42,35 +43,34 @@ }, "homepage": "https://github.com/angular/angular-cli", "dependencies": { - "@angular/compiler-cli": "22.1.0-rc.0", + "@angular/compiler-cli": "22.2.0-rc.0", "typescript": "6.0.3" }, "devDependencies": { - "@angular/animations": "22.1.0-rc.0", - "@angular/cdk": "22.1.0-next.4", - "@angular/common": "22.1.0-rc.0", - "@angular/compiler": "22.1.0-rc.0", - "@angular/core": "22.1.0-rc.0", - "@angular/forms": "22.1.0-rc.0", - "@angular/localize": "22.1.0-rc.0", - "@angular/material": "22.1.0-next.4", - "@angular/ng-dev": "https://github.com/angular/dev-infra-private-ng-dev-builds.git#7a67d5221a9efec2b69b102759d04223a3fc8552", - "@angular/platform-browser": "22.1.0-rc.0", - "@angular/platform-server": "22.1.0-rc.0", - "@angular/router": "22.1.0-rc.0", - "@angular/service-worker": "22.1.0-rc.0", - "@babel/core": "8.0.1", + "@angular/animations": "22.2.0-rc.0", + "@angular/cdk": "22.2.0-rc.0", + "@angular/common": "22.2.0-rc.0", + "@angular/compiler": "22.2.0-rc.0", + "@angular/core": "22.2.0-rc.0", + "@angular/forms": "22.2.0-rc.0", + "@angular/localize": "22.2.0-rc.0", + "@angular/material": "22.2.0-rc.0", + "@angular/ng-dev": "https://github.com/angular/dev-infra-private-ng-dev-builds.git#90a76327155df50e90a41fe76aed67b98a9cf954", + "@angular/platform-browser": "22.2.0-rc.0", + "@angular/platform-server": "22.2.0-rc.0", + "@angular/router": "22.2.0-rc.0", + "@angular/service-worker": "22.2.0-rc.0", + "@babel/core": "8.0.5", "@bazel/bazelisk": "1.28.1", "@bazel/buildifier": "8.2.1", "@bazel/ibazel": "^0.28.0", - "@eslint/compat": "2.1.0", - "@eslint/eslintrc": "3.3.6", + "@eslint/compat": "2.1.1", + "@eslint/eslintrc": "3.3.7", "@eslint/js": "10.0.1", - "@rollup/plugin-alias": "^6.0.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "16.0.3", - "@rollup/wasm-node": "4.62.2", + "@rollup/wasm-node": "4.63.3", "@stylistic/eslint-plugin": "^5.0.0", "@tony.ganchev/eslint-plugin-header": "~3.4.0", "@types/babel__core": "7.20.5", @@ -89,21 +89,20 @@ "@types/picomatch": "^4.0.0", "@types/progress": "^2.0.3", "@types/semver": "^7.3.12", - "@types/watchpack": "^2.4.4", "@types/yargs": "^17.0.20", "@types/yargs-parser": "^21.0.0", - "@typescript-eslint/eslint-plugin": "8.64.0", - "@typescript-eslint/parser": "8.64.0", + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", "ajv": "8.20.0", "buffer": "6.0.3", - "esbuild": "0.28.1", - "esbuild-wasm": "0.28.1", - "eslint": "10.7.0", + "esbuild": "0.28.2", + "esbuild-wasm": "0.28.2", + "eslint": "10.10.0", "eslint-config-prettier": "10.1.8", "eslint-plugin-import": "2.32.0", "express": "5.2.1", "fast-glob": "3.3.3", - "globals": "17.7.0", + "globals": "17.12.0", "http-proxy": "^1.18.1", "http-proxy-middleware": "4.2.0", "husky": "9.1.7", @@ -115,23 +114,21 @@ "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.2.0", + "karma-jasmine-html-reporter": "~2.3.0", "karma-source-map-support": "1.4.0", "lodash": "^4.17.21", - "magic-string": "1.0.0", + "magic-string": "1.4.1", "prettier": "^3.0.0", - "puppeteer": "25.3.0", + "puppeteer": "25.11.0", "quicktype-core": "26.0.0", - "rollup": "4.62.2", - "rollup-license-plugin": "~3.2.0", - "rollup-plugin-dts": "6.4.1", + "rollup": "4.63.3", + "rollup-plugin-dts": "6.5.1", "rollup-plugin-sourcemaps2": "0.5.8", "semver": "7.8.5", "source-map-support": "0.5.21", "tslib": "2.8.1", - "undici": "8.7.0", - "unenv": "^1.10.0", - "verdaccio": "6.8.0", + "undici": "8.10.2", + "verdaccio": "6.10.3", "verdaccio-auth-memory": "^13.0.0", "zone.js": "^0.16.0" }, diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index 7325ec88d35b..9a4de3b5aa64 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -82,13 +82,13 @@ ts_project( ":node_modules/@angular-devkit/core", ":node_modules/@angular/ssr", ":node_modules/@babel/core", - ":node_modules/@babel/helper-annotate-as-pure", - ":node_modules/@babel/helper-split-export-declaration", ":node_modules/@inquirer/confirm", ":node_modules/@oxc-project/types", + ":node_modules/@parcel/watcher", ":node_modules/@vitejs/plugin-basic-ssl", ":node_modules/beasties", ":node_modules/browserslist", + ":node_modules/chokidar", ":node_modules/https-proxy-agent", ":node_modules/istanbul-lib-instrument", ":node_modules/jsonc-parser", @@ -106,11 +106,12 @@ ts_project( ":node_modules/rolldown", ":node_modules/rollup", ":node_modules/sass", + ":node_modules/sass-embedded", ":node_modules/source-map-support", ":node_modules/tinyglobby", ":node_modules/vite", ":node_modules/vitest", - ":node_modules/watchpack", + ":node_modules/xxhash-wasm", "//:node_modules/@angular/common", "//:node_modules/@angular/compiler", "//:node_modules/@angular/compiler-cli", @@ -125,7 +126,6 @@ ts_project( "//:node_modules/@types/node", "//:node_modules/@types/picomatch", "//:node_modules/@types/semver", - "//:node_modules/@types/watchpack", "//:node_modules/esbuild", "//:node_modules/esbuild-wasm", "//:node_modules/karma", @@ -146,6 +146,7 @@ ts_project( ":build", ":node_modules/@angular-devkit/core", ":node_modules/@babel/core", + ":node_modules/oxc-parser", "//:node_modules/@angular/compiler-cli", "//:node_modules/@types/jasmine", "//:node_modules/esbuild", @@ -158,6 +159,7 @@ ts_project( jasmine_test( name = "test", data = [":unit_test_lib"], + shard_count = 4, ) ts_project( @@ -266,6 +268,7 @@ ts_project( "//:node_modules/@types/node", # unit test specific test deps + ":node_modules/@vitest/coverage-v8", ":node_modules/vitest", ":node_modules/jsdom", @@ -338,7 +341,7 @@ npm_package( ], stamp_files = [ "src/utils/version.js", - "src/tools/esbuild/utils.js", + "src/tools/esbuild/target.js", "src/utils/normalize-cache.js", "src/utils/supported-browsers.js", ], diff --git a/packages/angular/build/builders.json b/packages/angular/build/builders.json index 4dc9c9c245a6..7be59263804c 100644 --- a/packages/angular/build/builders.json +++ b/packages/angular/build/builders.json @@ -28,7 +28,7 @@ "unit-test": { "implementation": "./src/builders/unit-test", "schema": "./src/builders/unit-test/schema.json", - "description": "[EXPERIMENTAL] Run application unit tests." + "description": "Run application unit tests." } } } diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json index a049f92d4a73..9e0b0b7c4ff1 100644 --- a/packages/angular/build/package.json +++ b/packages/angular/build/package.json @@ -20,30 +20,31 @@ "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "workspace:0.0.0-EXPERIMENTAL-PLACEHOLDER", - "@babel/core": "8.0.1", - "@babel/helper-annotate-as-pure": "8.0.0", - "@babel/helper-split-export-declaration": "7.24.7", - "@inquirer/confirm": "6.1.1", + "@babel/core": "8.0.5", + "@inquirer/confirm": "6.3.2", + "@parcel/watcher": "2.6.0", "@vitejs/plugin-basic-ssl": "2.3.0", - "beasties": "0.4.3", + "beasties": "0.5.4", "browserslist": "^4.26.0", - "esbuild": "0.28.1", + "chokidar": "5.0.0", + "esbuild": "0.28.2", "https-proxy-agent": "9.1.0", "jsonc-parser": "3.3.1", - "listr2": "10.2.2", - "magic-string": "1.0.0", + "listr2": "11.1.0", + "magic-string": "1.4.1", "mrmime": "2.0.1", - "oxc-parser": "0.140.0", + "oxc-parser": "0.150.0", "parse5-html-rewriting-stream": "8.0.1", - "picomatch": "4.0.5", - "piscina": "5.2.0", - "rolldown": "1.2.0", - "sass": "1.101.0", + "picomatch": "4.0.7", + "piscina": "5.3.2", + "rolldown": "1.2.8", + "sass": "1.104.1", + "sass-embedded": "1.104.1", "semver": "7.8.5", "source-map-support": "0.5.21", "tinyglobby": "0.2.17", - "vite": "8.1.5", - "watchpack": "2.5.2" + "vite": "8.3.0", + "xxhash-wasm": "1.1.0" }, "optionalDependencies": { "lmdb": "3.5.6" @@ -51,15 +52,16 @@ "devDependencies": { "@angular-devkit/core": "workspace:*", "@angular/ssr": "workspace:*", - "@oxc-project/types": "0.140.0", + "@oxc-project/types": "0.150.0", + "@vitest/coverage-v8": "5.0.1", "istanbul-lib-instrument": "6.0.3", - "jsdom": "29.1.1", - "less": "4.6.7", - "ng-packagr": "22.1.0-next.3", - "postcss": "8.5.19", - "rollup": "4.62.2", + "jsdom": "30.0.1", + "less": "4.9.1", + "ng-packagr": "22.2.0-rc.0", + "postcss": "8.5.28", + "rollup": "4.63.3", "rxjs": "7.8.2", - "vitest": "4.1.10" + "vitest": "5.0.1" }, "peerDependencies": { "@angular/compiler": "0.0.0-ANGULAR-FW-PEER-DEP", @@ -79,7 +81,7 @@ "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", - "vitest": "^4.0.8" + "vitest": "^4.0.8 || ^5.0.0" }, "peerDependenciesMeta": { "@angular/core": { diff --git a/packages/angular/build/src/builders/application/build-action.ts b/packages/angular/build/src/builders/application/build-action.ts index d7dbdc8cfe21..b728ab9a8df5 100644 --- a/packages/angular/build/src/builders/application/build-action.ts +++ b/packages/angular/build/src/builders/application/build-action.ts @@ -7,16 +7,20 @@ */ import { BuilderContext } from '@angular-devkit/architect'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result'; +import { + BuildOutputAsset, + ExecutionResult, + RebuildState, +} from '../../tools/esbuild/bundler-execution-result'; import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-files'; -import { shutdownSassWorkerPool } from '../../tools/esbuild/stylesheets/sass-language'; +import { + resetSassWorkerPoolCaches, + shutdownSassWorkerPool, +} from '../../tools/esbuild/stylesheets/sass-language'; import { logMessages, withNoProgress, withSpinner } from '../../tools/esbuild/utils'; -import { ChangedFiles } from '../../tools/esbuild/watcher'; -import { shouldWatchRoot } from '../../utils/environment-options'; +import { initializeHash } from '../../utils/hash'; import { NormalizedCachedOptions } from '../../utils/normalize-cache'; -import { toPosixPath } from '../../utils/path'; +import { ChangedFiles } from '../../utils/watcher'; import { NormalizedApplicationBuildOptions, NormalizedOutputOptions } from './options'; import { ComponentUpdateResult, @@ -27,20 +31,7 @@ import { ResultMessage, } from './results'; -// Watch workspace for package manager changes -const packageWatchFiles = [ - // manifest can affect module resolution - 'package.json', - // npm lock file - 'package-lock.json', - // pnpm lock file - 'pnpm-lock.yaml', - // yarn lock file including Yarn PnP manifest files (https://yarnpkg.com/advanced/pnp-spec/) - 'yarn.lock', - '.pnp.cjs', - '.pnp.data.json', -]; - +// eslint-disable-next-line max-lines-per-function export async function* runEsBuildBuildAction( action: (rebuildState?: RebuildState) => Promise, options: { @@ -78,10 +69,12 @@ export async function* runEsBuildBuildAction( incrementalResults, } = options; + await initializeHash(); + const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress; // Initial build - let result: ExecutionResult; + let result: ExecutionResult | undefined; try { // Perform the build action result = await withProgress('Building...', () => action()); @@ -89,13 +82,13 @@ export async function* runEsBuildBuildAction( // Log all diagnostic (error/warning/logs) messages await logMessages(logger, result, colors, jsonLogs); } finally { - // Ensure Sass workers are shutdown if not watching - if (!watch) { + // Ensure Sass workers are shutdown if not watching or if the initial build failed + if (!watch || !result) { shutdownSassWorkerPool(); } } - let watcher: import('../../tools/esbuild/watcher').BuildWatcher | undefined; + let watcher: import('../../utils/watcher').BuildWatcher | undefined; let watchLoopStarted = false; try { // Setup watcher if watch mode enabled @@ -104,45 +97,18 @@ export async function* runEsBuildBuildAction( logger.info('Watch mode enabled. Watching for file changes...'); } - const ignored: string[] = [ - // Ignore the output and cache paths to avoid infinite rebuild cycles - outputOptions.base, - cacheOptions.basePath, - `${toPosixPath(workspaceRoot)}/**/.*/**`, - ]; - // Setup a watcher - const { createWatcher } = await import('../../tools/esbuild/watcher'); - watcher = createWatcher({ - polling: typeof poll === 'number', - interval: poll, - followSymlinks: preserveSymlinks, - ignored, + const { setupWatcher } = await import('../../utils/watcher'); + watcher = await setupWatcher({ + workspaceRoot, + projectRoot, + outputPath: outputOptions.base, + cacheOptions, + poll, + preserveSymlinks, + signal: options.signal, + watchFiles: result.watchFiles, }); - - // Setup abort support - options.signal?.addEventListener('abort', () => void watcher?.close()); - - // Watch the entire project root if 'NG_BUILD_WATCH_ROOT' environment variable is set - if (shouldWatchRoot) { - if (!preserveSymlinks) { - // Ignore all node modules directories to avoid excessive file watchers. - // Package changes are handled below by watching manifest and lock files. - // NOTE: this is not enable when preserveSymlinks is true as this would break `npm link` usages. - ignored.push('**/node_modules/**'); - - watcher.add( - packageWatchFiles - .map((file) => path.join(workspaceRoot, file)) - .filter((file) => existsSync(file)), - ); - } - - watcher.add(projectRoot); - } - - // Watch locations provided by the initial build result - watcher.add(result.watchFiles); } // Output the first build results after setting up the watcher to ensure that any code executed @@ -192,6 +158,11 @@ export async function* runEsBuildBuildAction( // Clear removed files from current watch files changes.removed.forEach((removedPath) => currentWatchFiles.delete(removedPath)); + const hasSassChanges = changes.all.some((f) => /\.(scss|sass|css)$/i.test(f)); + if (hasSassChanges) { + resetSassWorkerPoolCaches(); + } + const rebuildState = result.createRebuildState(changes); result = await withProgress('Changes detected. Rebuilding...', () => action(rebuildState)); @@ -215,6 +186,9 @@ export async function* runEsBuildBuildAction( // Remove any stale locations if the build was successful if (staleWatchFiles?.size) { watcher.remove([...staleWatchFiles]); + for (const staleFile of staleWatchFiles) { + currentWatchFiles.delete(staleFile); + } } for (const outputResult of emitOutputResults( @@ -267,34 +241,15 @@ function* emitOutputResults( // Use a full result if there is no rebuild state (no prior build result) if (!rebuildState || !changes) { - const result: FullResult = { - kind: ResultKind.Full, - warnings: warnings as ResultMessage[], - files: {}, - detail: { - externalMetadata, - htmlIndexPath, - htmlBaseHref, - outputOptions, - }, - }; - for (const file of assetFiles) { - result.files[file.destination] = { - type: BuildOutputFileType.Browser, - inputPath: file.source, - origin: 'disk', - }; - } - for (const file of outputFiles) { - result.files[file.path] = { - type: file.type, - contents: file.contents, - origin: 'memory', - hash: file.hash, - }; - } - - yield result; + yield createFullResult( + outputFiles, + assetFiles, + warnings, + outputOptions, + externalMetadata, + htmlIndexPath, + htmlBaseHref, + ); return; } @@ -314,7 +269,7 @@ function* emitOutputResults( added: [], removed: [], modified: [], - files: {}, + files: [], detail: { externalMetadata, htmlIndexPath, @@ -328,9 +283,10 @@ function* emitOutputResults( // Initially assume all previous output files have been removed const removedOutputFiles = new Map(previousOutputInfo); for (const file of outputFiles) { - removedOutputFiles.delete(file.path); + const key = `${file.type}:${file.path}`; + removedOutputFiles.delete(key); - const previousHash = previousOutputInfo.get(file.path)?.hash; + const previousHash = previousOutputInfo.get(key)?.hash; let needFile = false; if (previousHash === undefined) { needFile = true; @@ -347,12 +303,13 @@ function* emitOutputResults( incrementalResult.background = false; } - incrementalResult.files[file.path] = { + incrementalResult.files.push({ + path: file.path, type: file.type, contents: file.contents, origin: 'memory', hash: file.hash, - }; + }); } } @@ -373,11 +330,12 @@ function* emitOutputResults( hasCssUpdates ||= destination.endsWith('.css'); - incrementalResult.files[destination] = { + incrementalResult.files.push({ + path: destination, type: BuildOutputFileType.Browser, inputPath: source, origin: 'disk', - }; + }); } // Do not remove stale files yet if there are template updates. @@ -391,12 +349,12 @@ function* emitOutputResults( // Include the removed output and asset files incrementalResult.removed.push( - ...Array.from(removedOutputFiles, ([file, { type }]) => ({ - path: file, + ...Array.from(removedOutputFiles.values(), ({ type, path }) => ({ + path, type, })), - ...Array.from(removedAssetFiles.values(), (file) => ({ - path: file, + ...Array.from(removedAssetFiles.values(), (path) => ({ + path, type: BuildOutputFileType.Browser, })), ); @@ -413,9 +371,7 @@ function* emitOutputResults( added: incrementalResult.added.filter(isCssFilePath), removed: incrementalResult.removed.filter(({ path }) => isCssFilePath(path)), modified: incrementalResult.modified.filter(isCssFilePath), - files: Object.fromEntries( - Object.entries(incrementalResult.files).filter(([path]) => isCssFilePath(path)), - ), + files: incrementalResult.files.filter((file) => isCssFilePath(file.path)), }; yield styleResult; @@ -434,6 +390,42 @@ function* emitOutputResults( } } +function createFullResult( + outputFiles: readonly BuildOutputFile[], + assetFiles: readonly BuildOutputAsset[], + warnings: readonly unknown[], + outputOptions: NormalizedApplicationBuildOptions['outputOptions'], + externalMetadata: unknown, + htmlIndexPath: unknown, + htmlBaseHref: unknown, +): FullResult { + return { + kind: ResultKind.Full, + warnings: warnings as ResultMessage[], + files: [ + ...assetFiles.map(({ source, destination }) => ({ + path: destination, + type: BuildOutputFileType.Browser, + inputPath: source, + origin: 'disk' as const, + })), + ...outputFiles.map((file) => ({ + path: file.path, + type: file.type, + contents: file.contents, + origin: 'memory' as const, + hash: file.hash, + })), + ], + detail: { + externalMetadata, + htmlIndexPath, + htmlBaseHref, + outputOptions, + }, + }; +} + function isCssFilePath(filePath: string): boolean { return /\.css(?:\.map)?$/i.test(filePath); } diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts index 7d7a6e56b829..db3e1d2f180d 100644 --- a/packages/angular/build/src/builders/application/chunk-optimizer.ts +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -20,7 +20,7 @@ import type { Message, Metafile } from 'esbuild'; import assert from 'node:assert'; import type { Plugin } from 'rollup'; -import { BundleContextResult } from '../../tools/esbuild/bundler-context'; +import { BundleMergedContextResult } from '../../tools/esbuild/bundler-context'; import { type BuildOutputFile, BuildOutputFileType, @@ -113,11 +113,15 @@ function bundleOutputToEsbuildMetafile( continue; } + // Read once per module: in Rolldown, `renderedLength` is an uncached getter that + // copies the entire module code across the NAPI bridge on each access. + const { renderedLength } = renderedModule; + for (const [originalInputPath, originalInputInfo] of Object.entries( originalOutputEntry.inputs, )) { const proportion = originalInputInfo.bytesInOutput / totalOriginalBytesInModule; - const newBytesInOutput = Math.floor(renderedModule.renderedLength * proportion); + const newBytesInOutput = Math.floor(renderedLength * proportion); const existing = newOutputInputs[originalInputPath]; if (existing) { @@ -212,9 +216,9 @@ function createChunkOptimizationFailureMessage(message: string): Message { */ // eslint-disable-next-line max-lines-per-function export async function optimizeChunks( - original: BundleContextResult, + original: BundleMergedContextResult, sourcemap: boolean | 'hidden', -): Promise { +): Promise { // Failed builds cannot be optimized if (original.errors) { return original; @@ -235,7 +239,7 @@ export async function optimizeChunks( } // No action required if no browser main entrypoint or metafile for stats - if (!mainFile || !original.metafile) { + if (!mainFile || !original.metafiles.browser) { return original; } @@ -340,9 +344,9 @@ export async function optimizeChunks( } // Update metafile - const newMetafile = bundleOutputToEsbuildMetafile(optimizedOutput, original.metafile); + const newMetafile = bundleOutputToEsbuildMetafile(optimizedOutput, original.metafiles.browser); // Add back the outputs that were not part of the optimization - for (const [path, output] of Object.entries(original.metafile.outputs)) { + for (const [path, output] of Object.entries(original.metafiles.browser.outputs)) { if (usedChunks.has(path)) { continue; } @@ -350,11 +354,11 @@ export async function optimizeChunks( newMetafile.outputs[path] = output; for (const inputPath of Object.keys(output.inputs)) { if (!newMetafile.inputs[inputPath]) { - newMetafile.inputs[inputPath] = original.metafile.inputs[inputPath]; + newMetafile.inputs[inputPath] = original.metafiles.browser.inputs[inputPath]; } } } - original.metafile = newMetafile; + original.metafiles.browser = newMetafile; // Remove used chunks and associated sourcemaps from the original result original.outputFiles = original.outputFiles.filter( @@ -418,6 +422,7 @@ export async function optimizeChunks( const record = createInitialFileRecord(entryRecord.depth + 1); + original.initialFiles.set(importPath, record); entriesToAnalyze.push([importPath, record]); } } diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index 21beb57bf478..d3b628a169e2 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -8,7 +8,10 @@ import { BuilderContext } from '@angular-devkit/architect'; import { createAngularCompilation } from '../../tools/angular/compilation'; -import { AngularCompilationContext } from '../../tools/esbuild/angular/compilation-state'; +import { + AngularCompilationContext, + PrimaryCompilationContext, +} from '../../tools/esbuild/angular/compilation-state'; import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; import { generateBudgetStats } from '../../tools/esbuild/budget-stats'; import { BundleContextResult, BundlerContext } from '../../tools/esbuild/bundler-context'; @@ -17,14 +20,15 @@ import { BuildOutputFileType } from '../../tools/esbuild/bundler-files'; import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker'; import { LOCALE_DATA_BASE_MODULE } from '../../tools/esbuild/i18n-locale-plugin'; import { extractLicenses } from '../../tools/esbuild/license-extractor'; -import { profileAsync } from '../../tools/esbuild/profiling'; +import { transformSupportedBrowsersToTargets } from '../../tools/esbuild/target'; import { calculateEstimatedTransferSizes, + isZonelessApp, logBuildStats, - transformSupportedBrowsersToTargets, } from '../../tools/esbuild/utils'; import { BudgetCalculatorResult, checkBudgets } from '../../utils/bundle-calculator'; import { optimizeChunksThreshold } from '../../utils/environment-options'; +import { profileAsync } from '../../utils/profiling'; import { resolveAssets } from '../../utils/resolve-assets'; import { SERVER_APP_ENGINE_MANIFEST_FILENAME, @@ -36,6 +40,9 @@ import { inlineI18n, loadActiveTranslations } from './i18n'; import { NormalizedApplicationBuildOptions } from './options'; import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling'; +/** The esbuild error text prefix used to detect top-level await errors. */ +const TOP_LEVEL_AWAIT_ERROR_TEXT = + 'Top-level await is not available in the configured target environment'; // eslint-disable-next-line max-lines-per-function export async function executeBuild( options: NormalizedApplicationBuildOptions, @@ -71,15 +78,22 @@ export async function executeBuild( let bundlerContexts; let componentStyleBundler; let codeBundleCache; - let bundlingResult: BundleContextResult; + let bundlingIndividualResults: BundleContextResult[]; let templateUpdates: Map | undefined; let angularCompilationContext: AngularCompilationContext | undefined; + let executionResult: ExecutionResult | undefined; try { if (rebuildState) { bundlerContexts = rebuildState.rebuildContexts; componentStyleBundler = rebuildState.componentStyleBundler; codeBundleCache = rebuildState.codeBundleCache; templateUpdates = rebuildState.templateUpdates; + executionResult = new ExecutionResult( + bundlerContexts, + componentStyleBundler, + codeBundleCache, + templateUpdates, + ); // Reset template updates for new rebuild templateUpdates?.clear(); @@ -87,7 +101,7 @@ export async function executeBuild( // Bundle all contexts that do not require TypeScript changed file checks. // These will automatically use cached results based on the changed files. - bundlingResult = await BundlerContext.bundleAll( + bundlingIndividualResults = await BundlerContext.bundleAll( bundlerContexts.otherContexts, allFileChanges, ); @@ -101,7 +115,8 @@ export async function executeBuild( const result = await typescriptContext.bundle(forceTypeScriptRebuild); typescriptResults.push(result); } - bundlingResult = BundlerContext.mergeResults([bundlingResult, ...typescriptResults]); + + bundlingIndividualResults.push(...typescriptResults); } else { const target = transformSupportedBrowsersToTargets(browsers); codeBundleCache = new SourceFileCache(cacheOptions.enabled ? cacheOptions.path : undefined); @@ -113,7 +128,7 @@ export async function executeBuild( !!options.jit, !options.serverEntryPoint, ); - angularCompilationContext = new AngularCompilationContext(angularCompilation); + angularCompilationContext = new PrimaryCompilationContext(angularCompilation); bundlerContexts = setupBundlerContexts( options, target, @@ -122,9 +137,15 @@ export async function executeBuild( angularCompilationContext, templateUpdates, ); + executionResult = new ExecutionResult( + bundlerContexts, + componentStyleBundler, + codeBundleCache, + templateUpdates, + ); // Bundle everything on initial build - bundlingResult = await BundlerContext.bundleAll([ + bundlingIndividualResults = await BundlerContext.bundleAll([ ...bundlerContexts.typescriptContexts, ...bundlerContexts.otherContexts, ]); @@ -136,236 +157,278 @@ export async function executeBuild( componentStyleBundler.invalidate(rebuildState.fileChanges.all); const componentResults = await componentStyleBundler.bundleAllFiles(true, true); - bundlingResult = BundlerContext.mergeResults([bundlingResult, ...componentResults]); + bundlingIndividualResults.push(...componentResults); } - } catch (error) { - await angularCompilationContext?.dispose(); - throw error; - } - const executionResult = new ExecutionResult( - bundlerContexts, - componentStyleBundler, - codeBundleCache, - templateUpdates, - ); - executionResult.addWarnings(bundlingResult.warnings); - - // Add used external component style referenced files to be watched - if (options.externalRuntimeStyles) { - executionResult.extraWatchFiles.push(...componentStyleBundler.collectReferencedFiles()); - } + let bundlingResult = BundlerContext.mergeResults(bundlingIndividualResults); - // Return if the bundling has errors - if (bundlingResult.errors) { - executionResult.addErrors(bundlingResult.errors); - - return executionResult; - } + executionResult.addWarnings(bundlingResult.warnings); - // Optimize chunks if enabled and threshold is met. - // This pass uses Rollup/Rolldown to further optimize chunks generated by esbuild. - if (options.optimizationOptions.scripts) { - // Count lazy chunks (files not needed for initial load). - // Advanced chunk optimization is most beneficial when there are multiple lazy chunks. - const { metafile, initialFiles } = bundlingResult; - const lazyChunksCount = Object.keys(metafile.outputs).filter( - (path) => path.endsWith('.js') && !initialFiles.has(path), - ).length; - - // Only run if the number of lazy chunks meets the configured threshold. - // This avoids overhead for small projects with few chunks. - if (lazyChunksCount >= optimizeChunksThreshold) { - const { optimizeChunks } = await import('./chunk-optimizer'); - const optimizationResult = await profileAsync('OPTIMIZE_CHUNKS', () => - optimizeChunks( - bundlingResult, - options.sourcemapOptions.scripts ? !options.sourcemapOptions.hidden || 'hidden' : false, - ), - ); + // Add used external component style referenced files to be watched + if (options.externalRuntimeStyles) { + executionResult.extraWatchFiles.push(...componentStyleBundler.collectReferencedFiles()); + } - if (optimizationResult.errors) { - executionResult.addErrors(optimizationResult.errors); + // In non-watch mode, the bundler rebuild contexts and Angular compilation + // are no longer needed once bundling completes. Dispose them early to free + // worker processes, TypeScript ASTs, and caches before post-bundling steps + // execute or before returning bundling errors. + // This is fire-and-forget so worker teardown runs concurrently in the background + // without blocking the critical path of post-bundle optimization steps. + // Any in-flight disposal is awaited in the builder action's finally block. + if (!options.watch) { + void angularCompilationContext?.dispose(); + void executionResult.dispose(); + } - return executionResult; + // Return if the bundling has errors + if (bundlingResult.errors) { + // If Zone.js is used, augment top-level await errors with a more helpful message. + // esbuild's default error mentions "target environment" with browser versions, but + // the actual reason is that async/await is downleveled for Zone.js compatibility. + if (!isZonelessApp(options.polyfills)) { + for (const error of bundlingResult.errors) { + if (error.text?.startsWith(TOP_LEVEL_AWAIT_ERROR_TEXT)) { + error.notes ??= []; + error.notes.push({ + text: + 'Top-level await is not supported in applications that use Zone.js. ' + + 'Consider removing Zone.js or moving this code into an async function. \n' + + 'For more information about zoneless Angular applications, visit: https://angular.dev/guide/zoneless', + location: null, + }); + } + } } - bundlingResult = optimizationResult; + executionResult.addErrors(bundlingResult.errors); + + return executionResult; } - } - // Analyze external imports if external options are enabled - if (options.externalPackages || bundlingResult.externalConfiguration) { - const { - externalConfiguration = [], - externalImports: { browser = [], server = [] }, - } = bundlingResult; - // Similar to esbuild, --external:@foo/bar automatically implies --external:@foo/bar/*, - // which matches import paths like @foo/bar/baz. - // This means all paths within the @foo/bar package are also marked as external. - const exclusionsPrefixes = externalConfiguration.map((exclusion) => exclusion + '/'); - const exclusions = new Set(externalConfiguration); - const explicitExternal = new Set(); - - const isExplicitExternal = (dep: string): boolean => { - // Locale-data imports are injected by the builder itself (see i18n-locale-plugin) and must stay resolvable - // even when the containing package is externalized by the user. - if (dep.startsWith(`${LOCALE_DATA_BASE_MODULE}/`)) { - return false; - } + // Optimize chunks if enabled and threshold is met. + // This pass uses Rollup/Rolldown to further optimize chunks generated by esbuild. + if (options.optimizationOptions.scripts) { + // Count lazy chunks (files not needed for initial load). + // Advanced chunk optimization is most beneficial when there are multiple lazy chunks. + const { metafiles, initialFiles } = bundlingResult; + const lazyChunksCount = Object.keys(metafiles.browser.outputs || {}).filter( + (path) => path.endsWith('.js') && !initialFiles.has(path), + ).length; + + // Only run if the number of lazy chunks meets the configured threshold. + // This avoids overhead for small projects with few chunks. + if (lazyChunksCount >= optimizeChunksThreshold) { + const { optimizeChunks } = await import('./chunk-optimizer'); + const optimizationResult = await profileAsync('OPTIMIZE_CHUNKS', () => + optimizeChunks( + bundlingResult, + options.sourcemapOptions.scripts ? !options.sourcemapOptions.hidden || 'hidden' : false, + ), + ); + + if (optimizationResult.errors) { + executionResult.addErrors(optimizationResult.errors); + + return executionResult; + } - if (exclusions.has(dep)) { - return true; + bundlingResult = optimizationResult; } + } + + // Analyze external imports if external options are enabled + if (options.externalPackages || bundlingResult.externalConfiguration) { + const { + externalConfiguration = [], + externalImports: { browser = [], server = [] }, + } = bundlingResult; + // Similar to esbuild, --external:@foo/bar automatically implies --external:@foo/bar/*, + // which matches import paths like @foo/bar/baz. + // This means all paths within the @foo/bar package are also marked as external. + const exclusionsPrefixes = externalConfiguration.map((exclusion) => exclusion + '/'); + const exclusions = new Set(externalConfiguration); + const explicitExternal = new Set(); + + const isExplicitExternal = (dep: string): boolean => { + // Locale-data imports are injected by the builder itself (see i18n-locale-plugin) and must stay resolvable + // even when the containing package is externalized by the user. + if (dep.startsWith(`${LOCALE_DATA_BASE_MODULE}/`)) { + return false; + } - for (const prefix of exclusionsPrefixes) { - if (dep.startsWith(prefix)) { + if (exclusions.has(dep)) { return true; } - } - return false; - }; + for (const prefix of exclusionsPrefixes) { + if (dep.startsWith(prefix)) { + return true; + } + } + + return false; + }; + + const implicitBrowser: string[] = []; + for (const dep of browser) { + if (isExplicitExternal(dep)) { + explicitExternal.add(dep); + } else { + implicitBrowser.push(dep); + } + } - const implicitBrowser: string[] = []; - for (const dep of browser) { - if (isExplicitExternal(dep)) { - explicitExternal.add(dep); - } else { - implicitBrowser.push(dep); + const implicitServer: string[] = []; + for (const dep of server) { + if (isExplicitExternal(dep)) { + explicitExternal.add(dep); + } else { + implicitServer.push(dep); + } } + + executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]); } - const implicitServer: string[] = []; - for (const dep of server) { - if (isExplicitExternal(dep)) { - explicitExternal.add(dep); - } else { - implicitServer.push(dep); + const { + metafiles: { browser: browserMetafile, server: serverMetafile }, + initialFiles, + outputFiles, + } = bundlingResult; + const metafiles = [browserMetafile, serverMetafile]; + + executionResult.outputFiles.push(...outputFiles); + + // Analyze files for bundle budget failures if present + let budgetFailures: BudgetCalculatorResult[] | undefined; + if (options.budgets) { + const compatStats = generateBudgetStats(browserMetafile, outputFiles, initialFiles); + budgetFailures = [...checkBudgets(options.budgets, compatStats, true)]; + for (const { message, severity } of budgetFailures) { + if (severity === 'error') { + executionResult.addError(message); + } else { + executionResult.addWarning(message); + } } } - executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]); - } + // Calculate estimated transfer size if scripts are optimized + let estimatedTransferSizes; + if (optimizationOptions.scripts || optimizationOptions.styles.minify) { + estimatedTransferSizes = await calculateEstimatedTransferSizes(executionResult.outputFiles); + } - const { metafile, initialFiles, outputFiles } = bundlingResult; + // Check metafile for CommonJS module usage if optimizing scripts + if (optimizationOptions.scripts) { + const messages = checkCommonJSModules(browserMetafile, options.allowedCommonJsDependencies); + executionResult.addWarnings(messages); + } - executionResult.outputFiles.push(...outputFiles); + // Copy assets + if (assets) { + executionResult.addAssets(await resolveAssets(assets, workspaceRoot)); + } - // Analyze files for bundle budget failures if present - let budgetFailures: BudgetCalculatorResult[] | undefined; - if (options.budgets) { - const compatStats = generateBudgetStats(metafile, outputFiles, initialFiles); - budgetFailures = [...checkBudgets(options.budgets, compatStats, true)]; - for (const { message, severity } of budgetFailures) { - if (severity === 'error') { - executionResult.addError(message); - } else { - executionResult.addWarning(message); - } + // Extract and write licenses for used packages + if (options.extractLicenses) { + executionResult.addOutputFile( + '3rdpartylicenses.txt', + await extractLicenses(metafiles, workspaceRoot), + BuildOutputFileType.Root, + ); } - } - // Calculate estimated transfer size if scripts are optimized - let estimatedTransferSizes; - if (optimizationOptions.scripts || optimizationOptions.styles.minify) { - estimatedTransferSizes = await calculateEstimatedTransferSizes(executionResult.outputFiles); - } + // Watch input index HTML file if configured + if (options.indexHtmlOptions) { + executionResult.extraWatchFiles.push(options.indexHtmlOptions.input); + executionResult.htmlIndexPath = options.indexHtmlOptions.output; + executionResult.htmlBaseHref = options.baseHref; + } - // Check metafile for CommonJS module usage if optimizing scripts - if (optimizationOptions.scripts) { - const messages = checkCommonJSModules(metafile, options.allowedCommonJsDependencies); - executionResult.addWarnings(messages); - } + // Create server app engine manifest + if (serverEntryPoint) { + executionResult.addOutputFile( + SERVER_APP_ENGINE_MANIFEST_FILENAME, + generateAngularServerAppEngineManifest(i18nOptions, security.allowedHosts, baseHref), + BuildOutputFileType.ServerRoot, + ); + } - // Copy assets - if (assets) { - executionResult.addAssets(await resolveAssets(assets, workspaceRoot)); - } + // Perform i18n translation inlining if enabled + if (i18nOptions.shouldInline) { + const result = await inlineI18n(browserMetafile, options, executionResult, initialFiles); + executionResult.addErrors(result.errors); + executionResult.addWarnings(result.warnings); + executionResult.addPrerenderedRoutes(result.prerenderedRoutes); + } else { + const result = await executePostBundleSteps( + browserMetafile, + options, + executionResult.outputFiles, + executionResult.assetFiles, + initialFiles, + // Set lang attribute to the defined source locale if present + i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined, + ); - // Extract and write licenses for used packages - if (options.extractLicenses) { - executionResult.addOutputFile( - '3rdpartylicenses.txt', - await extractLicenses(metafile, workspaceRoot), - BuildOutputFileType.Root, - ); - } + // Deduplicate and add errors and warnings + executionResult.addErrors([...new Set(result.errors)]); + executionResult.addWarnings([...new Set(result.warnings)]); - // Watch input index HTML file if configured - if (options.indexHtmlOptions) { - executionResult.extraWatchFiles.push(options.indexHtmlOptions.input); - executionResult.htmlIndexPath = options.indexHtmlOptions.output; - executionResult.htmlBaseHref = options.baseHref; - } + executionResult.addPrerenderedRoutes(result.prerenderedRoutes); + executionResult.outputFiles.push(...result.additionalOutputFiles); + executionResult.assetFiles.push(...result.additionalAssets); + } - // Create server app engine manifest - if (serverEntryPoint) { - executionResult.addOutputFile( - SERVER_APP_ENGINE_MANIFEST_FILENAME, - generateAngularServerAppEngineManifest(i18nOptions, security.allowedHosts, baseHref), - BuildOutputFileType.ServerRoot, - ); - } + if (serverEntryPoint) { + executionResult.addOutputFile( + 'prerendered-routes.json', + JSON.stringify({ routes: executionResult.prerenderedRoutes }, null, 2), + BuildOutputFileType.Root, + ); + } - // Perform i18n translation inlining if enabled - if (i18nOptions.shouldInline) { - const result = await inlineI18n(metafile, options, executionResult, initialFiles); - executionResult.addErrors(result.errors); - executionResult.addWarnings(result.warnings); - executionResult.addPrerenderedRoutes(result.prerenderedRoutes); - } else { - const result = await executePostBundleSteps( - metafile, - options, - executionResult.outputFiles, - executionResult.assetFiles, - initialFiles, - // Set lang attribute to the defined source locale if present - i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined, - ); + // Write metafiles if stats option is enabled + if (options.stats) { + executionResult.addOutputFile( + 'browser-stats.json', + JSON.stringify(browserMetafile, null, 2), + BuildOutputFileType.Root, + ); - // Deduplicate and add errors and warnings - executionResult.addErrors([...new Set(result.errors)]); - executionResult.addWarnings([...new Set(result.warnings)]); + if (serverEntryPoint) { + executionResult.addOutputFile( + 'server-stats.json', + JSON.stringify(serverMetafile, null, 2), + BuildOutputFileType.Root, + ); + } + } - executionResult.addPrerenderedRoutes(result.prerenderedRoutes); - executionResult.outputFiles.push(...result.additionalOutputFiles); - executionResult.assetFiles.push(...result.additionalAssets); - } + if (!jsonLogs && !options.quiet) { + const changedFiles = + rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputInfo); + executionResult.addLog( + logBuildStats( + metafiles, + outputFiles, + initialFiles, + budgetFailures, + colors, + changedFiles, + estimatedTransferSizes, + !!ssrOptions, + verbose, + ), + ); + } - executionResult.addOutputFile( - 'prerendered-routes.json', - JSON.stringify({ routes: executionResult.prerenderedRoutes }, null, 2), - BuildOutputFileType.Root, - ); - - // Write metafile if stats option is enabled - if (options.stats) { - executionResult.addOutputFile( - 'stats.json', - JSON.stringify(metafile, null, 2), - BuildOutputFileType.Root, - ); - } + return executionResult; + } catch (error) { + await Promise.allSettled([angularCompilationContext?.dispose(), executionResult?.dispose()]); - if (!jsonLogs && !options.quiet) { - const changedFiles = - rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputInfo); - executionResult.addLog( - logBuildStats( - metafile, - outputFiles, - initialFiles, - budgetFailures, - colors, - changedFiles, - estimatedTransferSizes, - !!ssrOptions, - verbose, - ), - ); + throw error; } - - return executionResult; } diff --git a/packages/angular/build/src/builders/application/execute-post-bundle.ts b/packages/angular/build/src/builders/application/execute-post-bundle.ts index 198071c5a280..99e23bcb30c9 100644 --- a/packages/angular/build/src/builders/application/execute-post-bundle.ts +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -18,25 +18,22 @@ import { type InitialFileRecord, createOutputFile, } from '../../tools/esbuild/bundler-files'; -import { generateIndexHtml } from '../../tools/esbuild/index-html-generator'; import { maxWorkers } from '../../utils/environment-options'; import { SERVER_APP_MANIFEST_FILENAME, generateAngularServerAppManifest, } from '../../utils/server-rendering/manifest'; -import { - RouteRenderMode, - WritableSerializableRouteTreeNode, -} from '../../utils/server-rendering/models'; +import { WritableSerializableRouteTreeNode } from '../../utils/server-rendering/models'; import { prerenderPages } from '../../utils/server-rendering/prerender'; import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker'; +import { generateIndexHtml } from './index-html-generator'; import { injectDebugIds } from './inject-debug-ids'; import { INDEX_HTML_CSR, INDEX_HTML_SERVER, NormalizedApplicationBuildOptions } from './options'; import { OutputMode } from './schema'; /** * Run additional builds steps including SSG, AppShell, Index HTML file and Service worker generation. - * @param metafile An esbuild metafile object. + * @param browserMetafile An esbuild metafile object. * @param options The normalized application builder options used to create the build. * @param outputFiles The output files of an executed build. * @param assetFiles The assets of an executed build. @@ -45,7 +42,7 @@ import { OutputMode } from './schema'; */ // eslint-disable-next-line max-lines-per-function export async function executePostBundleSteps( - metafile: Metafile, + browserMetafile: Metafile, options: NormalizedApplicationBuildOptions, outputFiles: BuildOutputFile[], assetFiles: BuildOutputAsset[], @@ -123,7 +120,7 @@ export async function executePostBundleSteps( // Create server manifest const initialFilesPaths = new Set(initialFiles.keys()); if (serverEntryPoint && (outputMode || prerenderOptions || appShellOptions || ssrOptions)) { - const { manifestContent, serverAssetsChunks } = generateAngularServerAppManifest( + const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest( additionalHtmlOutputFiles, outputFiles, optimizationOptions.styles.inlineCritical ?? false, @@ -131,7 +128,7 @@ export async function executePostBundleSteps( locale, baseHref, initialFilesPaths, - metafile, + browserMetafile, publicPath, ); @@ -157,7 +154,13 @@ export async function executePostBundleSteps( 'The "index" option is required when using the "ssg" or "appShell" options.', ); - const { output, warnings, errors, serializableRouteTreeNode } = await prerenderPages( + const { + output, + warnings, + errors, + serializableRouteTreeNode, + prerenderedRoutes: generatedPrerenderedRoutes, + } = await prerenderPages( workspaceRoot, baseHref, appShellOptions, @@ -171,6 +174,7 @@ export async function executePostBundleSteps( allErrors.push(...errors); allWarnings.push(...warnings); + Object.assign(prerenderedRoutes, generatedPrerenderedRoutes); const indexHasBeenPrerendered = output[indexHtmlOptions.output]; for (const [path, { content, appShellRoute }] of Object.entries(output)) { @@ -195,10 +199,6 @@ export async function executePostBundleSteps( const serializableRouteTreeNodeForManifest: WritableSerializableRouteTreeNode = []; for (const metadata of serializableRouteTreeNode) { serializableRouteTreeNodeForManifest.push(metadata); - - if (metadata.renderMode === RouteRenderMode.Prerender && !metadata.route.includes('*')) { - prerenderedRoutes[metadata.route] = { headers: metadata.headers }; - } } if (outputMode === OutputMode.Server) { @@ -206,7 +206,7 @@ export async function executePostBundleSteps( const manifest = additionalOutputFiles.find((f) => f.path === SERVER_APP_MANIFEST_FILENAME); assert(manifest, `${SERVER_APP_MANIFEST_FILENAME} was not found in output files.`); - const { manifestContent, serverAssetsChunks } = generateAngularServerAppManifest( + const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest( additionalHtmlOutputFiles, outputFiles, optimizationOptions.styles.inlineCritical ?? false, @@ -214,7 +214,7 @@ export async function executePostBundleSteps( locale, baseHref, initialFilesPaths, - metafile, + browserMetafile, publicPath, ); diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts index c83f1a29a30a..839384421f1f 100644 --- a/packages/angular/build/src/builders/application/i18n.ts +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -8,16 +8,20 @@ import { BuilderContext } from '@angular-devkit/architect'; import type { Metafile } from 'esbuild'; +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { ExecutionResult, PrerenderedRoutesRecord, } from '../../tools/esbuild/bundler-execution-result'; import { BuildOutputFileType, InitialFileRecord } from '../../tools/esbuild/bundler-files'; -import { I18nInliner } from '../../tools/esbuild/i18n-inliner'; -import { maxWorkers } from '../../utils/environment-options'; +import { I18nInliner } from '../../tools/i18n'; +import { maxInlinerWorkers } from '../../utils/environment-options'; import { loadTranslations } from '../../utils/i18n-options'; import { createTranslationLoader } from '../../utils/load-translations'; +import { createProjectResolver } from '../../utils/resolve-project'; +import type { WorkerPool } from '../../utils/worker-pool'; import { executePostBundleSteps } from './execute-post-bundle'; import { NormalizedApplicationBuildOptions, getLocaleBaseHref } from './options'; @@ -28,28 +32,32 @@ import { NormalizedApplicationBuildOptions, getLocaleBaseHref } from './options' * @param options The normalized application builder options used to create the build. * @param executionResult The result of an executed build. * @param initialFiles A map containing initial file information for the executed build. + * @param workerPool An optional worker pool to use for running transformation tasks. */ export async function inlineI18n( metafile: Metafile, options: NormalizedApplicationBuildOptions, executionResult: ExecutionResult, initialFiles: Map, + workerPool?: WorkerPool, ): Promise<{ errors: string[]; warnings: string[]; prerenderedRoutes: PrerenderedRoutesRecord; }> { - const { i18nOptions, optimizationOptions, baseHref, cacheOptions } = options; + const { i18nOptions, baseHref, cacheOptions } = options; - // Create the multi-threaded inliner with common options and the files generated from the build. + // Create the multi-threaded inliner with common options. const inliner = new I18nInliner( { missingTranslation: i18nOptions.missingTranslationBehavior ?? 'warning', - outputFiles: executionResult.outputFiles, - shouldOptimize: optimizationOptions.scripts, + maxConcurrency: workerPool + ? Math.min(workerPool.maxThreads, maxInlinerWorkers) + : maxInlinerWorkers, persistentCachePath: cacheOptions.enabled ? cacheOptions.path : undefined, + localizeVersion: i18nOptions.localizeVersion, }, - maxWorkers, + workerPool, ); const inlineResult: { @@ -71,12 +79,29 @@ export async function inlineI18n( ); try { - for (const locale of i18nOptions.inlineLocales) { - // A locale specific set of files is returned from the inliner. - const localeInlineResult = await inliner.inlineForLocale( + const localesToInline = Array.from(i18nOptions.inlineLocales, (locale) => { + const localeDescription = i18nOptions.locales[locale]; + let translationIntegrity: string | undefined = ''; + for (const file of localeDescription.files) { + if (!file.integrity) { + translationIntegrity = undefined; + break; + } + translationIntegrity += (translationIntegrity ? '|' : '') + file.integrity; + } + + return { locale, - i18nOptions.locales[locale].translation, - ); + translation: localeDescription.translation, + translationIntegrity, + }; + }); + + const inlinedLocales = await inliner.inlineAll(executionResult.outputFiles, localesToInline); + + for (const locale of i18nOptions.inlineLocales) { + const localeInlineResult = inlinedLocales.get(locale); + assert(localeInlineResult !== undefined, 'Inlined result must exist for locale: ' + locale); const localeOutputFiles = localeInlineResult.outputFiles; inlineResult.errors.push(...localeInlineResult.errors); inlineResult.warnings.push(...localeInlineResult.warnings); @@ -141,19 +166,20 @@ export async function inlineI18n( if (executionResult.templateUpdates?.size) { // The development server only allows a single locale but issue a warning if used programmatically (experimental) // with multiple locales and template HMR. - if (i18nOptions.inlineLocales.size > 1) { + if (localesToInline.length > 1) { inlineResult.warnings.push( `Component HMR updates can only be inlined with a single locale. The first locale will be used.`, ); } - const firstLocale = [...i18nOptions.inlineLocales][0]; + const targetLocale = localesToInline[0]; for (const [id, content] of executionResult.templateUpdates) { const templateUpdateResult = await inliner.inlineTemplateUpdate( - firstLocale, - i18nOptions.locales[firstLocale].translation, + targetLocale.locale, + targetLocale.translation, content, id, + targetLocale.translationIntegrity, ); executionResult.templateUpdates.set(id, templateUpdateResult.code); inlineResult.errors.push(...templateUpdateResult.errors); @@ -176,6 +202,15 @@ export async function loadActiveTranslations( context: BuilderContext, i18n: NormalizedApplicationBuildOptions['i18nOptions'], ) { + if (!i18n.localizeVersion) { + try { + const projectResolve = createProjectResolver(context.workspaceRoot); + const manifestPath = projectResolve('@angular/localize/package.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf-8')) as { version?: string }; + i18n.localizeVersion = manifest.version; + } catch {} + } + // Load locale data and translations (if present) let loader; for (const [locale, desc] of Object.entries(i18n.locales)) { diff --git a/packages/angular/build/src/tools/esbuild/index-html-generator.ts b/packages/angular/build/src/builders/application/index-html-generator.ts similarity index 96% rename from packages/angular/build/src/tools/esbuild/index-html-generator.ts rename to packages/angular/build/src/builders/application/index-html-generator.ts index d0b0c81dc72f..d89eadf8b9b0 100644 --- a/packages/angular/build/src/tools/esbuild/index-html-generator.ts +++ b/packages/angular/build/src/builders/application/index-html-generator.ts @@ -9,9 +9,13 @@ import assert from 'node:assert'; import { createHash } from 'node:crypto'; import path from 'node:path'; -import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { + BuildOutputFile, + BuildOutputFileType, + InitialFileRecord, +} from '../../tools/esbuild/bundler-files'; import { IndexHtmlGenerator } from '../../utils/index-file/index-html-generator'; -import { BuildOutputFile, BuildOutputFileType, InitialFileRecord } from './bundler-files'; +import { NormalizedApplicationBuildOptions } from './options'; /** * The maximum number of module preload link elements that should be added for @@ -106,6 +110,7 @@ export async function generateIndexHtml( crossOrigin: crossOrigin, deployUrl: buildOptions.publicPath, postTransform: indexHtmlOptions.transformer, + outputPath: virtualOutputPath, generateDedicatedSSRContent: !!( buildOptions.ssrOptions || buildOptions.prerenderOptions || @@ -120,7 +125,6 @@ export async function generateIndexHtml( return indexHtmlGenerator.process({ baseHref, lang, - outputPath: virtualOutputPath, files: [...initialFiles] .filter(([, file]) => !file.serverFile) .map(([file, record]) => ({ diff --git a/packages/angular/build/src/builders/application/index.ts b/packages/angular/build/src/builders/application/index.ts index b31fc0a8f81b..59ded1cd8182 100644 --- a/packages/angular/build/src/builders/application/index.ts +++ b/packages/angular/build/src/builders/application/index.ts @@ -199,7 +199,7 @@ export async function* buildApplication( // Writes the output files to disk and ensures the containing directories are present const directoryExists = new Set(); try { - await emitFilesToDisk(Object.entries(result.files), async ([filePath, file]) => { + await emitFilesToDisk(result.files, async (file) => { if ( outputOptions.ignoreServer && (file.type === BuildOutputFileType.ServerApplication || @@ -208,7 +208,7 @@ export async function* buildApplication( return; } - const fullFilePath = generateFullPath(filePath, file.type, outputOptions); + const fullFilePath = generateFullPath(file.path, file.type, outputOptions); // Ensure output subdirectories exist const fileBasePath = path.dirname(fullFilePath); diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index 4ec810ff3c07..f7e60f4cc029 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -9,7 +9,6 @@ import type { BuilderContext } from '@angular-devkit/architect'; import type { Plugin } from 'esbuild'; import { access, constants, readFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; import path from 'node:path'; import { normalizeAssetPatterns, normalizeOptimization, normalizeSourceMaps } from '../../utils'; import { supportColor } from '../../utils/color'; @@ -19,9 +18,8 @@ import { IndexHtmlTransform } from '../../utils/index-file/index-html-generator' import { normalizeCacheOptions } from '../../utils/normalize-cache'; import { canonicalizePath } from '../../utils/path'; import { - SearchDirectory, - findTailwindConfiguration, generateSearchDirectories, + getTailwindConfig, loadPostcssConfiguration, } from '../../utils/postcss-configuration'; import { getProjectRootPaths, normalizeDirectoryPath } from '../../utils/project-metadata'; @@ -126,6 +124,25 @@ interface InternalOptions { * Suppress build summary and stats table. */ quiet?: boolean; + + /** + * Disables esbuild code splitting for the browser code bundle. + * + * Splitting emits shared chunks whose exports are read across chunk boundaries as live ESM + * bindings. A module hoisted into a shared chunk is wrapped in a lazy initializer, so its exported + * value is only assigned once that initializer runs. Runners that load the generated output + * through a module runner rather than the browser's own ESM implementation do not reliably + * preserve those bindings, and an importing chunk can observe the export as `undefined`. + * + * Test bundles are never downloaded by a browser, so there is nothing for splitting to optimize + * there. Used exclusively for tests and shouldn't be used for other kinds of builds. + */ + disableCodeSplitting?: boolean; + + /** + * An array of files to restrict the TypeScript compilation root names to. + */ + rootFiles?: string[]; } /** Full set of options for `application` builder. */ @@ -160,7 +177,7 @@ export async function normalizeOptions( options.preserveSymlinks ?? process.execArgv.includes('--preserve-symlinks'); // Setup base paths based on workspace root and project information - const workspaceRoot = canonicalizePath(context.workspaceRoot, preserveSymlinks); + const workspaceRoot = canonicalizePath(context.workspaceRoot); const projectMetadata = await context.getProjectMetadata(projectName); const { projectRoot, projectSourceRoot } = getProjectRootPaths(workspaceRoot, projectMetadata); @@ -266,7 +283,7 @@ export async function normalizeOptions( // Skip tailwind configuration if postcss is customized const tailwindConfiguration = postcssConfiguration ? undefined - : await getTailwindConfig(searchDirectories, workspaceRoot, context); + : await getTailwindConfig(searchDirectories, workspaceRoot, context.logger); let serverEntryPoint: string | undefined; if (typeof options.server === 'string') { @@ -370,6 +387,8 @@ export async function normalizeOptions( ? INDEX_HTML_CSR : indexBaseName; + const preloadInitialDefault = !options.serviceWorker; + indexHtmlOptions = { input: indexInput, output: indexOutput, @@ -381,8 +400,11 @@ export async function normalizeOptions( // [name, esm] ] as [string, boolean][], transformer: extensions?.indexHtmlTransformer, - // Preload initial defaults to true - preloadInitial: typeof options.index !== 'object' || (options.index.preloadInitial ?? true), + // Preload initial defaults to false when using a service worker, true otherwise + preloadInitial: + typeof options.index === 'object' + ? (options.index?.preloadInitial ?? preloadInitialDefault) + : preloadInitialDefault, }; } @@ -430,6 +452,7 @@ export async function normalizeOptions( verbose, watch, progress = true, + rootFiles, externalPackages, namedChunks, budgets, @@ -439,6 +462,7 @@ export async function normalizeOptions( partialSSRBuild = false, externalRuntimeStyles, instrumentForCoverage, + disableCodeSplitting, } = options; // Return all the normalized options @@ -475,6 +499,8 @@ export async function normalizeOptions( watch, workspaceRoot, entryPoints, + disableCodeSplitting, + rootFiles: rootFiles?.map((file: string) => path.resolve(workspaceRoot, file)), optimizationOptions, outputOptions, outExtension, @@ -517,36 +543,6 @@ export async function normalizeOptions( }; } -async function getTailwindConfig( - searchDirectories: SearchDirectory[], - workspaceRoot: string, - context: BuilderContext, -): Promise<{ file: string; package: string } | undefined> { - const tailwindConfigurationPath = findTailwindConfiguration(searchDirectories); - - if (!tailwindConfigurationPath) { - return undefined; - } - - // Create a node resolver from the configuration file - const resolver = createRequire(tailwindConfigurationPath); - try { - return { - file: tailwindConfigurationPath, - package: resolver.resolve('tailwindcss'), - }; - } catch { - const relativeTailwindConfigPath = path.relative(workspaceRoot, tailwindConfigurationPath); - context.logger.warn( - `Tailwind CSS configuration file found (${relativeTailwindConfigPath})` + - ` but the 'tailwindcss' package is not installed.` + - ` To enable Tailwind CSS, please install the 'tailwindcss' package.`, - ); - } - - return undefined; -} - /** * Normalize entry point options. To maintain compatibility with the legacy browser builder, we need a single `browser` * option which defines a single entry point. However, we also want to support multiple entry points as an internal option. diff --git a/packages/angular/build/src/builders/application/results.ts b/packages/angular/build/src/builders/application/results.ts index 6fa23f9f19cd..8e95176639f5 100644 --- a/packages/angular/build/src/builders/application/results.ts +++ b/packages/angular/build/src/builders/application/results.ts @@ -31,7 +31,7 @@ export interface FailureResult extends BaseResult { export interface FullResult extends BaseResult { kind: ResultKind.Full; - files: Record; + files: ResultFile[]; } export interface IncrementalResult extends BaseResult { @@ -40,13 +40,14 @@ export interface IncrementalResult extends BaseResult { added: string[]; removed: { path: string; type: BuildOutputFileType }[]; modified: string[]; - files: Record; + files: ResultFile[]; } export type ResultFile = DiskFile | MemoryFile; export interface BaseResultFile { origin: 'memory' | 'disk'; + path: string; type: BuildOutputFileType; } diff --git a/packages/angular/build/src/builders/application/schema.json b/packages/angular/build/src/builders/application/schema.json index 8bf30aec8298..8458d385a3fc 100644 --- a/packages/angular/build/src/builders/application/schema.json +++ b/packages/angular/build/src/builders/application/schema.json @@ -520,8 +520,7 @@ }, "preloadInitial": { "type": "boolean", - "default": true, - "description": "Generates 'preload', 'modulepreload', and 'preconnect' link elements for initial application files and resources." + "description": "Generates 'preload', 'modulepreload', and 'preconnect' link elements for initial application files and resources. Defaults to false when a service worker is enabled; otherwise true." } }, "required": ["input"] diff --git a/packages/angular/build/src/builders/application/setup-bundling.ts b/packages/angular/build/src/builders/application/setup-bundling.ts index bb53c0bae902..4380db080e87 100644 --- a/packages/angular/build/src/builders/application/setup-bundling.ts +++ b/packages/angular/build/src/builders/application/setup-bundling.ts @@ -19,7 +19,8 @@ import { import { BundlerContext } from '../../tools/esbuild/bundler-context'; import { createGlobalScriptsBundleOptions } from '../../tools/esbuild/global-scripts'; import { createGlobalStylesBundleOptions } from '../../tools/esbuild/global-styles'; -import { getSupportedNodeTargets } from '../../tools/esbuild/utils'; +import { MemoryLoadResultCache } from '../../tools/esbuild/load-result-cache'; +import { getSupportedNodeTargets } from '../../tools/esbuild/target'; import type { NormalizedApplicationBuildOptions } from './options'; /** @@ -94,11 +95,19 @@ export function setupBundlerContexts( // Global Stylesheets if (options.globalStyles.length > 0) { + const globalStylesCache = new MemoryLoadResultCache(); for (const initial of [true, false]) { const bundleOptions = createGlobalStylesBundleOptions(options, target, initial); if (bundleOptions) { otherContexts.push( - new BundlerContext(workspaceRoot, watch, bundleOptions, true, () => initial), + new BundlerContext( + workspaceRoot, + watch, + bundleOptions, + true, + () => initial, + globalStylesCache, + ), ); } } diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts index 22c4c32202bd..2de5c5f229ca 100644 --- a/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts @@ -132,5 +132,119 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { { outputLogsOnFailure: false }, ); }); + + it('rebuilds PostCSS stylesheet after error on rebuild from plugin dependency', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + styles: ['src/styles.css'], + }); + + await harness.writeFile( + 'test-plugin.js', + ` + const fs = require('fs'); + const path = require('path'); + module.exports = () => { + return { + postcssPlugin: 'test-plugin', + Once(root, { result }) { + const themePath = path.join(path.dirname(root.source.input.file), 'theme.json'); + result.messages.push({ + type: 'dependency', + file: themePath, + }); + const data = fs.readFileSync(themePath, 'utf-8'); + const json = JSON.parse(data); + root.append('body { color: ' + json.color + '; }'); + }, + }; + }; + module.exports.postcss = true; + `, + ); + await harness.writeFile( + '.postcssrc.json', + JSON.stringify({ + plugins: { + './test-plugin.js': {}, + }, + }), + ); + await harness.writeFile('src/styles.css', '/* base */'); + await harness.writeFile('src/theme.json', '{"color": "aqua"}'); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue'); + + await harness.writeFile('src/theme.json', 'invalid-json'); + }, + async ({ result }) => { + expect(result?.success).toBe(false); + + await harness.writeFile('src/theme.json', '{"color": "blue"}'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.toContain('color: blue'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('rebuilds PostCSS stylesheet after CSS syntax error on initial build from import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + styles: ['src/styles.css'], + }); + + await harness.writeFile( + 'noop-plugin.js', + ` + module.exports = () => ({ postcssPlugin: 'noop-plugin' }); + module.exports.postcss = true; + `, + ); + await harness.writeFile( + '.postcssrc.json', + JSON.stringify({ + plugins: { + './noop-plugin.js': {}, + }, + }), + ); + await harness.writeFile('src/styles.css', "@import './a.css';"); + await harness.writeFile('src/a.css', "a { ' }"); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBe(false); + + await harness.writeFile('src/a.css', 'body { color: aqua; }'); + }, + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue'); + + await harness.writeFile('src/a.css', 'body { color: blue; }'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.toContain('color: blue'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); }); }); diff --git a/packages/angular/build/src/builders/application/tests/behavior/top-level-await-error_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/top-level-await-error_spec.ts new file mode 100644 index 000000000000..b0220529ee28 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/top-level-await-error_spec.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Top-level await error message"', () => { + it('should show a Zone.js-specific error when top-level await is used with Zone.js', async () => { + await harness.writeFile( + 'src/main.ts', + ` + // The export makes this file a module, which is required for top-level await. + export const value = await Promise.resolve('test'); + console.log(value); + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: ['zone.js'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + 'Top-level await is not supported in applications that use Zone.js', + ), + }), + ); + }); + + it('should not show a Zone.js-specific error when top-level await is used without Zone.js', async () => { + await harness.writeFile( + 'src/main.ts', + ` + // The export makes this file a module, which is required for top-level await. + export const value = await Promise.resolve('test'); + console.log(value); + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringContaining( + 'Top-level await is not supported in applications that use Zone.js', + ), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts b/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts index 9c8384b29efc..a43d24f19c6d 100644 --- a/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts @@ -141,7 +141,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { indexFileContent.toContain('app-shell works!'); indexFileContent.toContain('p{color:#000}'); indexFileContent.toContain( - ``, + ``, ); }); @@ -170,7 +170,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { indexFileContent.toContain('app-shell works!'); indexFileContent.toContain('p{color:#000}'); indexFileContent.toContain( - ``, + ``, ); indexFileContent.toContain(''); }); - it(`should process the inline 'onload' handlers if a 'autoCsp' is true`, async () => { - const inlineCssProcessor = new InlineCriticalCssProcessor({ + it('should process the inline onload handlers and style tag when ngCspNonce is specified', async () => { + const { content, errors, warnings } = await inlineCriticalCss( + getContent('', ''), + '/dist/', + undefined, + false, readAsset, - autoCsp: true, - }); - - const { content } = await inlineCssProcessor.process(getContent(''), { - outputPath: '/dist/', - }); + ); + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(content).toContain( + '', + ); expect(content).toContain( - '', + '', ); + // Nonces shouldn't be added inside the `noscript` tags. + expect(content).toContain(''); + expect(content).toContain(' + + + + + `; + + const { content } = await inlineCriticalCss( + initialContent, + '/dist/', + undefined, + false, + readAsset, + ); + + expect(content).toContain(''); + }); + + it('should capture warnings from beasties', async () => { + const initialContent = ` + + + + + + `; + + const { warnings } = await inlineCriticalCss( + initialContent, + '/dist/', + undefined, + false, + readAsset, + ); + + expect(warnings.length).toBeGreaterThan(0); + expect(warnings[0]).toContain('missing.css'); + }); }); diff --git a/packages/angular/build/src/utils/index-file/nonce.ts b/packages/angular/build/src/utils/index-file/nonce.ts index d21f8b6900e5..f70a77242db3 100644 --- a/packages/angular/build/src/utils/index-file/nonce.ts +++ b/packages/angular/build/src/utils/index-file/nonce.ts @@ -42,7 +42,7 @@ export async function addNonce(html: string): Promise { } /** Finds the Angular nonce in an HTML string. */ -async function findNonce(html: string): Promise { +export async function findNonce(html: string): Promise { // Inexpensive check to avoid parsing the HTML when we're sure there's no nonce. if (!NONCE_ATTR_PATTERN.test(html)) { return null; diff --git a/packages/angular/build/src/utils/normalize-cache.ts b/packages/angular/build/src/utils/normalize-cache.ts index c2dc52514992..0c5aedc73684 100644 --- a/packages/angular/build/src/utils/normalize-cache.ts +++ b/packages/angular/build/src/utils/normalize-cache.ts @@ -21,6 +21,18 @@ export interface NormalizedCachedOptions { /** Disk cache base path. Example: `/.angular/cache`. */ basePath: string; + + /** + * Workspace-local disk cache path. Example: `/.angular/cache/v12.0.0`. + * Always resolves relative to the current workspace root, even within a Git worktree. + */ + localPath?: string; + + /** + * Workspace-local disk cache base path. Example: `/.angular/cache`. + * Always resolves relative to the current workspace root, even within a Git worktree. + */ + localBasePath?: string; } interface CacheMetadata { @@ -82,7 +94,7 @@ function getCacheBasePath(workspaceRoot: string, cachePathSetting: string): stri export function normalizeCacheOptions( projectMetadata: unknown, - worspaceRoot: string, + workspaceRoot: string, ): NormalizedCachedOptions { const cacheMetadata = hasCacheMetadata(projectMetadata) ? projectMetadata.cli.cache : {}; @@ -106,11 +118,14 @@ export function normalizeCacheOptions( } } - const cacheBasePath = getCacheBasePath(worspaceRoot, path); + const cacheBasePath = getCacheBasePath(workspaceRoot, path); + const localCacheBasePath = isAbsolute(path) ? path : resolve(workspaceRoot, path); return { enabled: cacheEnabled, basePath: cacheBasePath, path: join(cacheBasePath, VERSION), + localBasePath: localCacheBasePath, + localPath: join(localCacheBasePath, VERSION), }; } diff --git a/packages/angular/build/src/utils/normalize-cache_spec.ts b/packages/angular/build/src/utils/normalize-cache_spec.ts index c8a72870d62a..cfa98ea8a69e 100644 --- a/packages/angular/build/src/utils/normalize-cache_spec.ts +++ b/packages/angular/build/src/utils/normalize-cache_spec.ts @@ -29,6 +29,8 @@ describe('normalizeCacheOptions', () => { const options = normalizeCacheOptions({}, workspaceRoot); expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(workspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); }); it('should resolve cache path relative to main repository root in a git worktree', async () => { @@ -51,6 +53,58 @@ describe('normalizeCacheOptions', () => { const options = normalizeCacheOptions({}, worktreeRoot); expect(options.basePath).toBe(resolve(mainRepoRoot, '.angular/cache')); + expect(options.path).toBe(resolve(mainRepoRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(resolve(worktreeRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(worktreeRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + }); + + it('should resolve local cache path relative to worktree root with custom relative path', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions( + { cli: { cache: { path: 'custom-cache' } } }, + worktreeRoot, + ); + + expect(options.basePath).toBe(resolve(mainRepoRoot, 'custom-cache')); + expect(options.path).toBe(resolve(mainRepoRoot, 'custom-cache', '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(resolve(worktreeRoot, 'custom-cache')); + expect(options.localPath).toBe(resolve(worktreeRoot, 'custom-cache', '0.0.0-PLACEHOLDER')); + }); + + it('should preserve absolute cache path for both shared and local paths', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + const absoluteCachePath = join(tempDir, 'absolute-cache'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions( + { cli: { cache: { path: absoluteCachePath } } }, + worktreeRoot, + ); + + expect(options.basePath).toBe(absoluteCachePath); + expect(options.path).toBe(resolve(absoluteCachePath, '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(absoluteCachePath); + expect(options.localPath).toBe(resolve(absoluteCachePath, '0.0.0-PLACEHOLDER')); }); it('should resolve cache path relative to workspace root in a git submodule', async () => { @@ -69,6 +123,8 @@ describe('normalizeCacheOptions', () => { const options = normalizeCacheOptions({}, submoduleRoot); expect(options.basePath).toBe(resolve(submoduleRoot, '.angular/cache')); + expect(options.localBasePath).toBe(resolve(submoduleRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(submoduleRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); }); it('should resolve cache path relative to workspace root when there is no git repository', async () => { @@ -78,5 +134,7 @@ describe('normalizeCacheOptions', () => { const options = normalizeCacheOptions({}, workspaceRoot); expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(workspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); }); }); diff --git a/packages/angular/build/src/utils/path.ts b/packages/angular/build/src/utils/path.ts index 4056972f790c..a236878d134e 100644 --- a/packages/angular/build/src/utils/path.ts +++ b/packages/angular/build/src/utils/path.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import { realpathSync } from 'node:fs'; import { isAbsolute, posix, relative, resolve } from 'node:path'; import { platform } from 'node:process'; @@ -53,18 +52,15 @@ export function isSubDirectory(parent: string, child: string): boolean { } /** - * Canonicalizes a file path by normalising Windows drive-letter casing to uppercase - * and optionally resolving symbolic links. + * Canonicalizes a file path by normalising Windows drive-letter casing to uppercase. * * @param pathString - The file path to canonicalize. - * @param preserveSymlinks - If true, symbolic links will not be resolved. * @returns The canonicalized file path. */ -export function canonicalizePath(pathString: string, preserveSymlinks = false): string { - const resolved = preserveSymlinks ? pathString : realpathSync(pathString); - if (platform === 'win32' && /^[a-z]:/.test(resolved)) { - return resolved[0].toUpperCase() + resolved.slice(1); +export function canonicalizePath(pathString: string): string { + if (platform === 'win32' && /^[a-z]:/.test(pathString)) { + return pathString[0].toUpperCase() + pathString.slice(1); } - return resolved; + return pathString; } diff --git a/packages/angular/build/src/utils/path_spec.ts b/packages/angular/build/src/utils/path_spec.ts index 84c7d8ac31c4..cd8ca3372433 100644 --- a/packages/angular/build/src/utils/path_spec.ts +++ b/packages/angular/build/src/utils/path_spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { isSubDirectory } from './path'; +import { canonicalizePath, isSubDirectory } from './path'; describe('isSubDirectory', () => { it('should return true for a direct child', () => { @@ -39,3 +39,17 @@ describe('isSubDirectory', () => { expect(isSubDirectory('/foo/bar', '/foo/bar/..baz/qux')).toBeTrue(); }); }); + +describe('canonicalizePath', () => { + it('should return the path unmodified on POSIX systems', () => { + expect(canonicalizePath('/foo/bar/baz')).toBe('/foo/bar/baz'); + }); + + if (process.platform === 'win32') { + it('should uppercase Windows drive-letter casing', () => { + expect(canonicalizePath('c:/foo/bar')).toBe('C:/foo/bar'); + expect(canonicalizePath('d:\\foo\\bar')).toBe('D:\\foo\\bar'); + expect(canonicalizePath('C:/foo/bar')).toBe('C:/foo/bar'); + }); + } +}); diff --git a/packages/angular/build/src/utils/postcss-configuration.ts b/packages/angular/build/src/utils/postcss-configuration.ts index 6f3f1f3671f9..abde0e632b30 100644 --- a/packages/angular/build/src/utils/postcss-configuration.ts +++ b/packages/angular/build/src/utils/postcss-configuration.ts @@ -7,7 +7,8 @@ */ import { readFile, readdir } from 'node:fs/promises'; -import { join } from 'node:path'; +import { createRequire } from 'node:module'; +import { join, relative } from 'node:path'; export interface PostcssConfiguration { plugins: [name: string, options?: object | string][]; @@ -62,6 +63,35 @@ export function findTailwindConfiguration( return findFile(searchDirectories, tailwindConfigFiles); } +export async function getTailwindConfig( + searchDirectories: SearchDirectory[], + workspaceRoot: string, + logger?: { warn(message: string): void }, +): Promise<{ file: string; package: string } | undefined> { + const tailwindConfigurationPath = findTailwindConfiguration(searchDirectories); + if (!tailwindConfigurationPath) { + return undefined; + } + + // Create a node resolver from the configuration file + const resolver = createRequire(tailwindConfigurationPath); + try { + return { + file: tailwindConfigurationPath, + package: resolver.resolve('tailwindcss'), + }; + } catch { + const relativeTailwindConfigPath = relative(workspaceRoot, tailwindConfigurationPath); + logger?.warn( + `Tailwind CSS configuration file found (${relativeTailwindConfigPath})` + + ` but the 'tailwindcss' package is not installed.` + + ` To enable Tailwind CSS, please install the 'tailwindcss' package.`, + ); + } + + return undefined; +} + async function readPostcssConfiguration( configurationFile: string, ): Promise { diff --git a/packages/angular/build/src/tools/esbuild/profiling.ts b/packages/angular/build/src/utils/profiling.ts similarity index 97% rename from packages/angular/build/src/tools/esbuild/profiling.ts rename to packages/angular/build/src/utils/profiling.ts index 80fd26c32d0e..762a739bd318 100644 --- a/packages/angular/build/src/tools/esbuild/profiling.ts +++ b/packages/angular/build/src/utils/profiling.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { debugPerformance } from '../../utils/environment-options'; +import { debugPerformance } from './environment-options'; let cumulativeDurations: Map | undefined; diff --git a/packages/angular/build/src/utils/purge-cache.ts b/packages/angular/build/src/utils/purge-cache.ts index 5851d052d54a..545d2dd97bba 100644 --- a/packages/angular/build/src/utils/purge-cache.ts +++ b/packages/angular/build/src/utils/purge-cache.ts @@ -19,25 +19,41 @@ export async function purgeStaleBuildCache(context: BuilderContext): Promise d.isDirectory()) - .map((d) => join(basePath, d.name)) - .filter((cachePath) => cachePath !== path) - .map((stalePath) => rm(stalePath, { force: true, recursive: true, maxRetries: 3 })); + for (const base of basePaths) { + let baseEntries; + try { + baseEntries = await readdir(base, { withFileTypes: true }); + } catch { + // No purging possible if base path does not exist or cannot otherwise be accessed + continue; + } + + const currentPath = base === localBasePath ? localPath : path; + if (!currentPath) { + // Avoid purging if current path is unavailable to prevent deleting the active cache + continue; + } - await Promise.allSettled(entriesToDelete); + const entriesToDelete = baseEntries + .filter((d) => d.isDirectory()) + .map((d) => join(base, d.name)) + .filter((cachePath) => cachePath !== currentPath) + .map((stalePath) => rm(stalePath, { force: true, recursive: true, maxRetries: 3 })); + + await Promise.allSettled(entriesToDelete); + } } diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts index 1d0d9df32d30..6f21bfbc739b 100644 --- a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts @@ -16,6 +16,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; * @note For some unknown reason, setting `globalThis.ngServerMode = true` does not work when using ESM loader hooks. */ const NG_SERVER_MODE_INIT_BYTES = new TextEncoder().encode('var ngServerMode=true;'); +const UTF8_DECODER = new TextDecoder(); /** * Node.js ESM loader to redirect imports to in memory files. @@ -25,12 +26,12 @@ const NG_SERVER_MODE_INIT_BYTES = new TextEncoder().encode('var ngServerMode=tru const MEMORY_URL_SCHEME = 'memory://'; export interface ESMInMemoryFileLoaderWorkerData { - outputFiles: Record; + outputFiles: Record; workspaceRoot: string; } let memoryVirtualRootUrl: string; -let outputFiles: Record; +let outputFiles: Record; export function initialize(data: ESMInMemoryFileLoaderWorkerData) { // This path does not actually exist but is used to overlay the in memory files with the @@ -84,7 +85,7 @@ export function resolve( } catch {} if ( - specifierUrl?.pathname && + specifierUrl?.href.startsWith(memoryVirtualRootUrl) && Object.hasOwn(outputFiles, specifierUrl.href.slice(memoryVirtualRootUrl.length)) ) { return { @@ -114,12 +115,14 @@ export async function load(url: string, context: { format?: string | null }, nex // Load the file from memory if the URL is based in the virtual root if (url.startsWith(memoryVirtualRootUrl)) { - const source = outputFiles[url.slice(memoryVirtualRootUrl.length)]; - assert(source !== undefined, 'Resolved in-memory ESM file should always exist: ' + url); + const rawSource = outputFiles[url.slice(memoryVirtualRootUrl.length)]; + assert(rawSource !== undefined, 'Resolved in-memory ESM file should always exist: ' + url); + + const source = typeof rawSource === 'string' ? rawSource : UTF8_DECODER.decode(rawSource); // In-memory files have already been transformer during bundling and can be returned directly return { - format, + format: format ?? 'module', shortCircuit: true, source, }; @@ -128,14 +131,15 @@ export async function load(url: string, context: { format?: string | null }, nex // Only module files potentially require transformation. Angular libraries that would // need linking are ESM only. if (format === 'module' && isFileProtocol(url)) { - const filePath = fileURLToPath(url); - let source = await readFile(filePath); - - if (filePath.includes('@angular/')) { - // Prepend 'var ngServerMode=true;' to the source. - source = Buffer.concat([NG_SERVER_MODE_INIT_BYTES, source]); + // Check url instead of filePath so the check is robust across Windows and POSIX path separators. + if (!url.includes('/@angular/')) { + return nextLoad(url, context); } + const filePath = fileURLToPath(url); + const fileBytes = await readFile(filePath); + const source = Buffer.concat([NG_SERVER_MODE_INIT_BYTES, fileBytes]); + return { format, shortCircuit: true, diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts new file mode 100644 index 000000000000..2b7622145378 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts @@ -0,0 +1,246 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { initialize, load, resolve } from './loader-hooks'; +import { createSharedServerFiles } from './utils'; + +describe('esm-in-memory-loader loader-hooks', () => { + const workspaceRoot = '/mock/workspace/root'; + const sharedFiles = createSharedServerFiles({ + 'main.server.mjs': 'export const main = true;', + 'chunk-abc.mjs': 'export const chunk = "abc";', + 'nested/chunk-sub.mjs': 'export const sub = "sub";', + 'utf8.mjs': 'export const text = "🔥 UTF-8 🚀";', + 'empty.mjs': '', + }); + + beforeEach(() => { + initialize({ + workspaceRoot, + outputFiles: sharedFiles, + }); + }); + + describe('resolve', () => { + it('should resolve memory:// URLs into virtual filesystem URLs', () => { + const nextResolve = jasmine.createSpy('nextResolve'); + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const result = resolve(memoryUrl, { parentURL: undefined }, nextResolve); + + expect(nextResolve).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.url).toContain('/.angular/prerender-root/'); + expect(result.url).toContain('/main.server.mjs'); + }); + + it('should fail when memory:// URL is malformed', () => { + const nextResolve = jasmine.createSpy('nextResolve'); + expect(() => { + resolve('memory://::invalid', { parentURL: undefined }, nextResolve); + }).toThrowMatching((err: Error) => + err.message.includes('External code attempted to use malformed memory scheme'), + ); + expect(nextResolve).not.toHaveBeenCalled(); + }); + + it('should resolve relative specifiers within in-memory files', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine.createSpy('nextResolve'); + const result = resolve('./chunk-abc.mjs', { parentURL }, nextResolve); + + expect(nextResolve).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.url).toContain('/chunk-abc.mjs'); + }); + + it('should resolve relative specifiers navigating parent directories within in-memory files', () => { + const memoryUrl = new URL('./nested/chunk-sub.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine.createSpy('nextResolve'); + const result = resolve('../chunk-abc.mjs', { parentURL }, nextResolve); + + expect(nextResolve).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.url).toContain('/chunk-abc.mjs'); + }); + + it('should fail when relative specifier from in-memory file does not exist', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine.createSpy('nextResolve'); + expect(() => { + resolve('./non-existent.mjs', { parentURL }, nextResolve); + }).toThrowMatching((err: Error) => + err.message.includes('In-memory ESM relative file should always exist'), + ); + expect(nextResolve).not.toHaveBeenCalled(); + }); + + it('should rewrite parentURL to index.js in virtual root for bare package specifiers', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine + .createSpy('nextResolve') + .and.returnValue({ url: 'file:///some/node_modules/@angular/core/index.js' }); + const result = resolve('@angular/core', { parentURL }, nextResolve); + + expect(nextResolve).toHaveBeenCalledWith( + '@angular/core', + jasmine.objectContaining({ + parentURL: jasmine.stringMatching(/\/\.angular\/prerender-root\/[^/]+\/index\.js$/), + }), + ); + expect(result.url).toBe('file:///some/node_modules/@angular/core/index.js'); + }); + + it('should delegate to nextResolve for external non-memory URLs', () => { + const nextResolve = jasmine + .createSpy('nextResolve') + .and.returnValue({ url: 'file:///some/ext/pkg' }); + const result = resolve('some-pkg', { parentURL: 'file:///some/ext/file.js' }, nextResolve); + + expect(nextResolve).toHaveBeenCalledWith('some-pkg', { + parentURL: 'file:///some/ext/file.js', + }); + expect(result.url).toBe('file:///some/ext/pkg'); + }); + }); + + describe('load', () => { + it('should load in-memory file source from SharedArrayBuffer backed Uint8Array', async () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = await load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe('export const main = true;'); + }); + + it('should load in-memory file with multi-byte UTF-8 characters', async () => { + const memoryUrl = new URL('./utf8.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = await load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe('export const text = "🔥 UTF-8 🚀";'); + }); + + it('should load in-memory file with empty content', async () => { + const memoryUrl = new URL('./empty.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = await load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe(''); + }); + + it('should load in-memory file source with non-zero byteOffset in Uint8Array', async () => { + const target = 'export const sliced = 42;'; + const fullBuffer = Buffer.from(`__PADDING__${target}__MORE__`); + const offset = Buffer.byteLength('__PADDING__', 'utf-8'); + const length = Buffer.byteLength(target, 'utf-8'); + const subView = new Uint8Array(fullBuffer.buffer, fullBuffer.byteOffset + offset, length); + + initialize({ + workspaceRoot, + outputFiles: { + 'sliced.mjs': subView, + }, + }); + + const memoryUrl = new URL('./sliced.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = await load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe(target); + }); + + it('should load in-memory file source when outputFiles contain string values', async () => { + initialize({ + workspaceRoot, + outputFiles: { + 'string-file.mjs': 'export const fromString = 1;', + }, + }); + + const memoryUrl = new URL('./string-file.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = await load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe('export const fromString = 1;'); + }); + + it('should reject when in-memory file does not exist in outputFiles', async () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nonExistentVirtualUrl = rootResolve.url.replace('main.server.mjs', 'missing.mjs'); + const nextLoad = jasmine.createSpy('nextLoad'); + + await expectAsync( + load(nonExistentVirtualUrl, { format: 'module' }, nextLoad), + ).toBeRejectedWithError(/Resolved in-memory ESM file should always exist/); + expect(nextLoad).not.toHaveBeenCalled(); + }); + + it('should delegate to nextLoad for non-angular file URLs', async () => { + const nextLoad = jasmine.createSpy('nextLoad').and.resolveTo({ format: 'module' }); + const result = await load( + 'file:///workspace/node_modules/rxjs/index.js', + { format: 'module' }, + nextLoad, + ); + + expect(nextLoad).toHaveBeenCalledWith('file:///workspace/node_modules/rxjs/index.js', { + format: 'module', + }); + expect(result.format).toBe('module'); + }); + + it('should delegate to nextLoad for non-memory non-file URLs', async () => { + const nextLoad = jasmine.createSpy('nextLoad').and.resolveTo({ format: 'builtin' }); + const result = await load('node:fs', { format: 'builtin' }, nextLoad); + + expect(nextLoad).toHaveBeenCalledWith('node:fs'); + expect(result.format).toBe('builtin'); + }); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts index 3af354f6ba0f..7eb2bd1f5c49 100644 --- a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts @@ -11,3 +11,41 @@ import { pathToFileURL } from 'node:url'; export const IMPORT_EXEC_ARGV = '--import=' + pathToFileURL(join(__dirname, 'register-hooks.js')).href; + +/** + * Creates a shared zero-copy `Uint8Array` backed by a `SharedArrayBuffer` for the given file content. + */ +export function createSharedFile(content: string | Uint8Array): Uint8Array { + if (typeof content === 'string') { + const byteLength = Buffer.byteLength(content, 'utf-8'); + const sab = new SharedArrayBuffer(byteLength); + Buffer.from(sab).write(content, 'utf-8'); + + return new Uint8Array(sab); + } + + if (content.buffer instanceof SharedArrayBuffer) { + return content; + } + + const sab = new SharedArrayBuffer(content.byteLength); + const view = new Uint8Array(sab); + view.set(content); + + return view; +} + +/** + * Creates shared zero-copy `Uint8Array` views backed by `SharedArrayBuffer` for all output files. + */ +export function createSharedServerFiles( + outputFiles: Record, +): Record { + const sharedFiles: Record = {}; + + for (const [key, value] of Object.entries(outputFiles)) { + sharedFiles[key] = createSharedFile(value); + } + + return sharedFiles; +} diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils_spec.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils_spec.ts new file mode 100644 index 000000000000..b7bb2844b66e --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils_spec.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { createSharedFile, createSharedServerFiles } from './utils'; + +describe('server-rendering shared file utilities', () => { + describe('createSharedFile', () => { + it('should create a Uint8Array backed by SharedArrayBuffer from string', () => { + const text = 'console.log("hello world");'; + const shared = createSharedFile(text); + + expect(shared instanceof Uint8Array).toBeTrue(); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(Buffer.byteLength(text, 'utf-8')); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(text); + }); + + it('should correctly handle multi-byte UTF-8 characters', () => { + const text = 'export const greeting = "🚀 Привет, мир! 🌍";'; + const shared = createSharedFile(text); + + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(text); + }); + + it('should handle empty string', () => { + const shared = createSharedFile(''); + + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(0); + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(''); + }); + + it('should handle empty Uint8Array', () => { + const shared = createSharedFile(new Uint8Array(0)); + + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(0); + }); + + it('should return the same Uint8Array if already backed by SharedArrayBuffer', () => { + const text = 'export default 42;'; + const shared1 = createSharedFile(text); + const shared2 = createSharedFile(shared1); + + expect(shared2).toBe(shared1); + expect(shared2.buffer).toBe(shared1.buffer); + }); + + it('should return existing Uint8Array view if already backed by SharedArrayBuffer with non-zero byteOffset', () => { + const text = '0123456789abcdef'; + const shared1 = createSharedFile(text); + const subView = new Uint8Array(shared1.buffer, 4, 8); + const shared2 = createSharedFile(subView); + + expect(shared2).toBe(subView); + expect(shared2.byteOffset).toBe(4); + expect(shared2.byteLength).toBe(8); + + const decoded = Buffer.from(shared2.buffer, shared2.byteOffset, shared2.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe('456789ab'); + }); + + it('should convert standard Uint8Array to SharedArrayBuffer backed Uint8Array', () => { + const regularBuffer = Buffer.from('export const test = true;'); + expect(regularBuffer.buffer instanceof SharedArrayBuffer).toBeFalse(); + + const shared = createSharedFile(regularBuffer); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe('export const test = true;'); + }); + + it('should handle Uint8Array slice with non-zero byteOffset from standard ArrayBuffer', () => { + const target = 'export const data = 123;'; + const fullBuffer = Buffer.from(`__PREFIX__${target}__SUFFIX__`); + const offset = Buffer.byteLength('__PREFIX__', 'utf-8'); + const length = Buffer.byteLength(target, 'utf-8'); + const subView = new Uint8Array(fullBuffer.buffer, fullBuffer.byteOffset + offset, length); + + const shared = createSharedFile(subView); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(length); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(target); + }); + }); + + describe('createSharedServerFiles', () => { + it('should convert all entries in a record to SharedArrayBuffer backed Uint8Arrays', () => { + const files: Record = { + 'main.server.mjs': 'export default function() {}', + 'chunk-1.mjs': 'export const x = 1;', + 'render-utils.mjs': Buffer.from('export const helper = () => true;'), + }; + + const sharedFiles = createSharedServerFiles(files); + + expect(Object.keys(sharedFiles)).toEqual([ + 'main.server.mjs', + 'chunk-1.mjs', + 'render-utils.mjs', + ]); + + for (const [key, shared] of Object.entries(sharedFiles)) { + expect(shared instanceof Uint8Array).toBeTrue(); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + + const expectedText = + typeof files[key] === 'string' ? files[key] : (files[key] as Buffer).toString('utf-8'); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(expectedText); + } + }); + + it('should handle empty file map', () => { + const sharedFiles = createSharedServerFiles({}); + expect(Object.keys(sharedFiles).length).toBe(0); + }); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts index bd294b1e7e4b..ab1d71a851cb 100644 --- a/packages/angular/build/src/utils/server-rendering/manifest.ts +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -7,14 +7,19 @@ */ import type { Metafile } from 'esbuild'; +import { Buffer } from 'node:buffer'; import { extname } from 'node:path'; -import { runInThisContext } from 'node:vm'; -import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { + INDEX_HTML_SERVER, + NormalizedApplicationBuildOptions, +} from '../../builders/application/options'; import { type BuildOutputFile, BuildOutputFileType, createOutputFile, } from '../../tools/esbuild/bundler-files'; +import { findNonce } from '../index-file/nonce'; +import { joinUrlParts } from '../url'; export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs'; export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs'; @@ -33,11 +38,6 @@ export const SERVER_GENERATED_EXTERNALS = new Set([ './' + SERVER_APP_ENGINE_MANIFEST_FILENAME, ]); -interface FilesMapping { - path: string; - dynamicImport: boolean; -} - const MAIN_SERVER_OUTPUT_FILENAME = 'main.server.mjs'; /** @@ -143,7 +143,7 @@ export default { * - `manifestContent`: A string of the SSR manifest content. * - `serverAssetsChunks`: An array of build output files containing the generated assets for the server. */ -export function generateAngularServerAppManifest( +export async function generateAngularServerAppManifest( additionalHtmlOutputFiles: Map, outputFiles: BuildOutputFile[], inlineCriticalCss: boolean, @@ -153,16 +153,23 @@ export function generateAngularServerAppManifest( initialFiles: Set, metafile: Metafile, publicPath: string | undefined, -): { +): Promise<{ manifestContent: string; serverAssetsChunks: BuildOutputFile[]; -} { +}> { const serverAssetsChunks: BuildOutputFile[] = []; const serverAssets: Record = {}; + const criticalCssPlans: unknown[] = []; + let nonce: string | undefined; + + // TODO(alanagius): This is done here as we do not use module resolution bundler/node16 + const { compileSheet, encodePlan } = (await import( + 'beasties/compiler' as string + )) as typeof import('beasties/compiler', { with: { 'resolution-mode': 'import' } }); for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) { const extension = extname(file.path); - if (extension === '.html' || (inlineCriticalCss && extension === '.css')) { + if (extension === '.html') { const jsChunkFilePath = `assets-chunks/${file.path.replace(/[./]/g, '_')}.mjs`; const escapedContent = escapeUnsafeChars(file.text); @@ -174,15 +181,30 @@ export function generateAngularServerAppManifest( ), ); - // This is needed because JavaScript engines script parser convert `\r\n` to `\n` in template literals, - // which can result in an incorrect byte length. - const size = runInThisContext(`new TextEncoder().encode(\`${escapedContent}\`).byteLength`); + // JavaScript engine script parsers normalize `\r\n` (2 bytes in UTF-8) to `\n` (1 byte in UTF-8) in template literals. + // Subtracting the count of `\r\n` occurrences avoids allocating any temporary strings or match arrays for large assets. + let size = Buffer.byteLength(file.text); + let pos = file.text.indexOf('\r\n'); + while (pos !== -1) { + size--; + pos = file.text.indexOf('\r\n', pos + 2); + } serverAssets[file.path] = `{size: ${size}, hash: '${file.hash}', text: () => import('./${jsChunkFilePath}').then(m => m.default)}`; + } else if (inlineCriticalCss && extension === '.css') { + const sheet = compileSheet(file.text, { + href: joinUrlParts(publicPath ?? '', file.path), + }); + criticalCssPlans.push(encodePlan(sheet)); } } + const indexHtml = additionalHtmlOutputFiles.get(INDEX_HTML_SERVER)?.text; + if (indexHtml) { + nonce = (await findNonce(indexHtml)) ?? undefined; + } + // When routes have been extracted, mappings are no longer needed, as preloads will be included in the metadata. const entryPointToBrowserMapping = routes?.length ? undefined @@ -191,9 +213,10 @@ export function generateAngularServerAppManifest( const manifestContent = ` export default { bootstrap: () => import('./main.server.mjs').then(m => m.default), - inlineCriticalCss: ${inlineCriticalCss}, baseHref: '${baseHref}', - locale: ${JSON.stringify(locale)}, +${criticalCssPlans.length ? ` criticalCssPlans: ${JSON.stringify(criticalCssPlans)},\n` : ''}${ + nonce ? ` nonce: ${JSON.stringify(nonce)},\n` : '' + } locale: ${JSON.stringify(locale)}, routes: ${JSON.stringify(routes, undefined, 2)}, entryPointToBrowserMapping: ${JSON.stringify(entryPointToBrowserMapping, undefined, 2)}, assets: { diff --git a/packages/angular/build/src/utils/server-rendering/manifest_spec.ts b/packages/angular/build/src/utils/server-rendering/manifest_spec.ts new file mode 100644 index 000000000000..76b7c00b13ff --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/manifest_spec.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Metafile } from 'esbuild'; +import { BuildOutputFileType, createOutputFile } from '../../tools/esbuild/bundler-files'; +import { initializeHash } from '../hash'; +import { generateAngularServerAppManifest } from './manifest'; + +describe('generateAngularServerAppManifest', () => { + beforeAll(async () => { + await initializeHash(); + }); + + const dummyMetafile = { inputs: {}, outputs: {} } as unknown as Metafile; + + it('should include criticalCssPlans when inlineCriticalCss is true', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + true, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(manifestContent).toContain('criticalCssPlans: ['); + expect(manifestContent).toContain('styles.css'); + expect(manifestContent).not.toContain('nonce:'); + }); + + it('should not include criticalCssPlans when inlineCriticalCss is false', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + false, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(manifestContent).not.toContain('criticalCssPlans:'); + }); + + it('should extract template nonce from index HTML when present', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + true, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(manifestContent).toContain('nonce: "{% nonce %}"'); + }); + + it('should not include css files in serverAssetsChunks or assets', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + true, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(serverAssetsChunks.some((chunk) => chunk.path.includes('styles'))).toBeFalse(); + expect(manifestContent).not.toContain("'styles.css':"); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index b4f81f03a800..98502c63587d 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -10,13 +10,20 @@ import { readFile } from 'node:fs/promises'; import { extname, posix } from 'node:path'; import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; import { OutputMode } from '../../builders/application/schema'; -import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result'; +import { + BuildOutputAsset, + PrerenderedRoutesRecord, +} from '../../tools/esbuild/bundler-execution-result'; import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-files'; import { assertIsError } from '../error'; import { toPosixPath } from '../path'; import { addLeadingSlash, addTrailingSlash, joinUrlParts, stripLeadingSlash } from '../url'; import { WorkerPool } from '../worker-pool'; -import { IMPORT_EXEC_ARGV } from './esm-in-memory-loader/utils'; +import { + IMPORT_EXEC_ARGV, + createSharedFile, + createSharedServerFiles, +} from './esm-in-memory-loader/utils'; import { SERVER_APP_MANIFEST_FILENAME } from './manifest'; import { RouteRenderMode, @@ -25,7 +32,7 @@ import { SerializableRouteTreeNode, WritableSerializableRouteTreeNode, } from './models'; -import type { RenderWorkerData } from './render-worker'; +import type { RenderResult, RenderWorkerData } from './render-worker'; import { generateRedirectStaticPage } from './utils'; type PrerenderOptions = NormalizedApplicationBuildOptions['prerenderOptions']; @@ -61,9 +68,10 @@ export async function prerenderPages( output: PrerenderOutput; warnings: string[]; errors: string[]; + prerenderedRoutes: PrerenderedRoutesRecord; serializableRouteTreeNode: SerializableRouteTreeNode; }> { - const outputFilesForWorker: Record = {}; + const rawOutputFiles: Record = {}; const serverBundlesSourceMaps = new Map(); const warnings: string[] = []; const errors: string[] = []; @@ -77,23 +85,25 @@ export async function prerenderPages( if (extname(path) === '.map') { serverBundlesSourceMaps.set(path.slice(0, -4), text); } else { - outputFilesForWorker[path] = text; + rawOutputFiles[path] = text; } } // Inline sourcemap into JS file. This is needed to make Node.js resolve sourcemaps // when using `--enable-source-maps` when using in memory files. for (const [filePath, map] of serverBundlesSourceMaps) { - const jsContent = outputFilesForWorker[filePath]; + const jsContent = rawOutputFiles[filePath]; if (jsContent) { - outputFilesForWorker[filePath] = + rawOutputFiles[filePath] = jsContent + - `\n//# sourceMappingURL=` + + '\n//# sourceMappingURL=' + `data:application/json;base64,${Buffer.from(map).toString('base64')}`; } } serverBundlesSourceMaps.clear(); + const outputFilesForWorker = createSharedServerFiles(rawOutputFiles); + const assetsReversed: Record = {}; for (const { source, destination } of assets) { // Assets are not stored with baseHref when using i18n, @@ -161,6 +171,7 @@ export async function prerenderPages( errors, warnings, output: {}, + prerenderedRoutes: {}, serializableRouteTreeNode, }; } @@ -169,9 +180,13 @@ export async function prerenderPages( // We could re-generate it from the start, but that would require a number of options to be passed down. const manifest = outputFilesForWorker[SERVER_APP_MANIFEST_FILENAME]; if (manifest) { - outputFilesForWorker[SERVER_APP_MANIFEST_FILENAME] = manifest.replace( - 'routes: undefined,', - `routes: ${JSON.stringify(serializableRouteTreeNodeForPrerender, undefined, 2)},`, + const manifestText = new TextDecoder().decode(manifest); + + outputFilesForWorker[SERVER_APP_MANIFEST_FILENAME] = createSharedFile( + manifestText.replace( + 'routes: undefined,', + `routes: ${JSON.stringify(serializableRouteTreeNodeForPrerender, undefined, 2)},`, + ), ); } @@ -190,10 +205,22 @@ export async function prerenderPages( errors.push(...renderingErrors); + const prerenderedRoutes: PrerenderedRoutesRecord = {}; + const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; + + for (const metadata of serializableRouteTreeNodeForPrerender) { + const outPath = getRouteOutPath(metadata.route, baseHrefPathnameWithLeadingSlash); + + if (output[outPath]) { + prerenderedRoutes[metadata.route] = { headers: metadata.headers }; + } + } + return { errors, warnings, output, + prerenderedRoutes, serializableRouteTreeNode, }; } @@ -204,7 +231,7 @@ async function renderPages( serializableRouteTreeNode: SerializableRouteTreeNode, maxThreads: number, workspaceRoot: string, - outputFilesForWorker: Record, + outputFilesForWorker: Record, assetFilesForWorker: Record, outputMode: OutputMode | undefined, appShellRoute: string | undefined, @@ -214,15 +241,52 @@ async function renderPages( }> { const output: PrerenderOutput = {}; const errors: string[] = []; - const workerExecArgv = [IMPORT_EXEC_ARGV]; + const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; + const appShellRouteWithoutBaseHref = appShellRoute + ? addLeadingSlash(getRouteWithoutBaseHref(appShellRoute, baseHrefPathnameWithLeadingSlash)) + : undefined; + + const routesToRender: { route: string; outPath: string; isAppShell: boolean }[] = []; + + for (const { route, redirectTo } of serializableRouteTreeNode) { + // Remove the base href from the file output path. + const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathnameWithLeadingSlash); + const outPath = getRouteOutPath(route, baseHrefPathnameWithLeadingSlash); + + if (typeof redirectTo === 'string') { + output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false }; + + continue; + } + + routesToRender.push({ + route, + outPath, + isAppShell: appShellRouteWithoutBaseHref === routeWithoutBaseHref, + }); + } + + if (routesToRender.length === 0) { + return { + errors, + output, + }; + } + + // Batch routes to reduce IPC overhead while ensuring enough batches exist for load balancing across worker threads. + const batchSize = Math.max(1, Math.min(50, Math.ceil(routesToRender.length / (maxThreads * 4)))); + const numBatches = Math.ceil(routesToRender.length / batchSize); + const effectiveMaxThreads = Math.min(numBatches, maxThreads); + + const workerExecArgv = [IMPORT_EXEC_ARGV]; if (sourcemap) { workerExecArgv.push('--enable-source-maps'); } const renderWorker = new WorkerPool({ filename: require.resolve('./render-worker'), - maxThreads: Math.min(serializableRouteTreeNode.length, maxThreads), + maxThreads: effectiveMaxThreads, workerData: { workspaceRoot, outputFiles: outputFilesForWorker, @@ -238,45 +302,46 @@ async function renderPages( }); try { - const renderingPromises: Promise[] = []; - const appShellRouteWithLeadingSlash = appShellRoute && addLeadingSlash(appShellRoute); - const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; - - for (const { route, redirectTo } of serializableRouteTreeNode) { - // Remove the base href from the file output path. - const routeWithoutBaseHref = addTrailingSlash(route).startsWith( - baseHrefPathnameWithLeadingSlash, - ) - ? addLeadingSlash(route.slice(baseHrefPathnameWithLeadingSlash.length)) - : route; - - const outPath = stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html')); - - if (typeof redirectTo === 'string') { - output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false }; + const routeOutPathMap = new Map(); + for (const item of routesToRender) { + routeOutPathMap.set(item.route, item); + } - continue; - } + const renderingPromises: Promise[] = []; - const render: Promise = renderWorker.run({ url: route }); - const renderResult: Promise = render - .then((content) => { - if (content !== null) { - output[outPath] = { - content, - appShellRoute: appShellRouteWithLeadingSlash === routeWithoutBaseHref, - }; + for (let i = 0; i < routesToRender.length; i += batchSize) { + const batch = routesToRender.slice(i, i + batchSize); + const urls = batch.map((item) => item.route); + const renderBatchPromise: Promise = renderWorker.run(urls); + const batchResultPromise = renderBatchPromise + .then((results) => { + for (const result of results) { + if ('error' in result) { + errors.push( + `An error occurred while prerendering route '${result.url}'.\n\n${result.error}`, + ); + continue; + } + + const routeInfo = routeOutPathMap.get(result.url); + if (routeInfo) { + output[routeInfo.outPath] = { + content: result.content, + appShellRoute: routeInfo.isAppShell, + }; + } } }) .catch((err) => { assertIsError(err); - errors.push( - `An error occurred while prerendering route '${route}'.\n\n${err.stack ?? err.message ?? err.code ?? err}`, - ); - void renderWorker.destroy(); + for (const url of urls) { + errors.push( + `An error occurred while prerendering route '${url}'.\n\n${err.stack ?? err.message ?? err.code ?? err}`, + ); + } }); - renderingPromises.push(renderResult); + renderingPromises.push(batchResultPromise); } await Promise.all(renderingPromises); @@ -293,7 +358,7 @@ async function renderPages( async function getAllRoutes( workspaceRoot: string, baseHref: string, - outputFilesForWorker: Record, + outputFilesForWorker: Record, assetFilesForWorker: Record, appShellOptions: AppShellOptions | undefined, prerenderOptions: PrerenderOptions | undefined, @@ -384,3 +449,15 @@ async function getAllRoutes( void renderWorker.destroy(); } } + +function getRouteWithoutBaseHref(route: string, baseHrefPathname: string): string { + return addTrailingSlash(route).startsWith(baseHrefPathname) + ? addLeadingSlash(route.slice(baseHrefPathname.length)) + : route; +} + +function getRouteOutPath(route: string, baseHrefPathname: string): string { + const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathname); + + return stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html')); +} diff --git a/packages/angular/build/src/utils/server-rendering/render-worker.ts b/packages/angular/build/src/utils/server-rendering/render-worker.ts index 7ded0550b826..df1fdb4ea103 100644 --- a/packages/angular/build/src/utils/server-rendering/render-worker.ts +++ b/packages/angular/build/src/utils/server-rendering/render-worker.ts @@ -8,6 +8,7 @@ import { workerData } from 'node:worker_threads'; import type { OutputMode } from '../../builders/application/schema'; +import { assertIsError } from '../error'; import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; import { DEFAULT_URL, launchServer } from './launch-server'; @@ -20,9 +21,17 @@ export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData { hasSsrEntry: boolean; } -export interface RenderOptions { - url: string; -} +export type RenderResultItem = + | { + url: string; + content: string; + } + | { + url: string; + error: string; + }; + +export type RenderResult = RenderResultItem[]; /** * This is passed as workerData when setting up the worker via the `piscina` package. @@ -35,16 +44,12 @@ const { outputMode, hasSsrEntry } = workerData as { let serverURL = DEFAULT_URL; /** - * Renders each route in routes and writes them to //index.html. + * Renders a single route URL. */ -async function renderPage({ url }: RenderOptions): Promise { - const { ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp } = - await loadEsmModuleFromMemory('./main.server.mjs'); - - const angularServerApp = getOrCreateAngularServerApp({ - allowStaticRouteRender: true, - }); - +async function renderPage( + url: string, + angularServerApp: { handle: (request: Request) => Promise }, +): Promise { const response = await angularServerApp.handle( new Request(new URL(url, serverURL), { signal: AbortSignal.timeout(30_000) }), ); @@ -58,6 +63,39 @@ async function renderPage({ url }: RenderOptions): Promise { return location ? generateRedirectStaticPage(location) : response.text(); } +/** + * Renders routes in batch or individual URL. + */ +async function renderPages(urls: string[]): Promise { + const { ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp } = + await loadEsmModuleFromMemory('./main.server.mjs'); + + const angularServerApp = getOrCreateAngularServerApp({ + allowStaticRouteRender: true, + }); + + const results: RenderResult = []; + for (const currentUrl of urls) { + try { + const content = await renderPage(currentUrl, angularServerApp); + + if (content === null) { + throw new Error('The content returned was empty.'); + } + + results.push({ url: currentUrl, content }); + } catch (err) { + assertIsError(err); + results.push({ + url: currentUrl, + error: err.stack ?? err.message ?? err.code ?? `${err}`, + }); + } + } + + return results; +} + async function initialize() { // Load the compiler because `@angular/ssr/node` depends on `@angular/` packages, // which must be processed by the runtime linker, even if they are not used. @@ -69,7 +107,7 @@ async function initialize() { patchFetchToLoadInMemoryAssets(serverURL); - return renderPage; + return renderPages; } export default initialize(); diff --git a/packages/angular/build/src/utils/source-map.ts b/packages/angular/build/src/utils/source-map.ts index 3b91bfb5b5ad..4fa97dd222c1 100644 --- a/packages/angular/build/src/utils/source-map.ts +++ b/packages/angular/build/src/utils/source-map.ts @@ -11,17 +11,95 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +export const SOURCEMAP_COMMENT_PREFIX = '//# sourceMappingURL='; +export const SOURCEMAP_COMMENT_BYTES = Buffer.from(SOURCEMAP_COMMENT_PREFIX); + +/** + * Checks for a single trailing `//# sourceMappingURL=` comment on a raw buffer. + * + * @param data The raw byte buffer to inspect. + * @returns An object containing the sliced code buffer and URL snippet if a single trailing comment exists, + * `null` if no sourcemap comment exists in the buffer, or `undefined` if multiple/non-trailing comments exist. + */ +export function findTrailingSourceMapComment( + data: Uint8Array, +): { code: Uint8Array; urlLine: string } | null | undefined { + const dataBuffer = Buffer.isBuffer(data) + ? data + : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + + const firstIndex = dataBuffer.indexOf(SOURCEMAP_COMMENT_BYTES); + if (firstIndex === -1) { + return null; + } + + const lastIndex = dataBuffer.lastIndexOf(SOURCEMAP_COMMENT_BYTES); + // Skip any preceding horizontal whitespace (spaces/tabs) to find the start of the line. + let prevIdx = lastIndex - 1; + while (prevIdx >= 0 && (dataBuffer[prevIdx] === 32 || dataBuffer[prevIdx] === 9)) { + prevIdx--; + } + // Ensure the comment starts at the beginning of a line or the start of the file, + // preventing false positives for occurrences inside inline string literals or code. + const isLineStart = prevIdx < 0 || dataBuffer[prevIdx] === 10 || dataBuffer[prevIdx] === 13; + + if (firstIndex === lastIndex && isLineStart) { + const urlLine = dataBuffer + .subarray(lastIndex + SOURCEMAP_COMMENT_BYTES.length) + .toString('utf-8'); + + return { + code: dataBuffer.subarray(0, prevIdx < 0 ? 0 : prevIdx + 1), + urlLine, + }; + } + + return undefined; +} + /** * Removes `//# sourceMappingURL=` comments safely from the given JavaScript code, * ignoring any occurrences that are inside string literals, template literals, or block comments. * - * It uses a lightweight state-machine parser to accurately handle nested template literals. + * For raw Uint8Array / Buffer inputs, it optimizes performance by inspecting trailing byte sequences + * to slice the buffer directly without full string decoding. * - * @param code The JavaScript source code. + * @param code The JavaScript source code as a string or Uint8Array. * @returns The code with top-level sourcemap comments removed. */ -export function removeSourceMappingURL(code: string): string { +export function removeSourceMappingURL(code: string): string; +export function removeSourceMappingURL(code: Uint8Array): Uint8Array; +export function removeSourceMappingURL(code: string | Uint8Array): string | Uint8Array { + if (typeof code === 'string') { + return removeSourceMappingURLFromString(code); + } + + const trailing = findTrailingSourceMapComment(code); + if (trailing === null) { + return code; + } + + if (trailing && isTrailingSourceMapComment(trailing.urlLine)) { + return trailing.code; + } + + // Fallback to full decode and state-machine stripping for multiple comments or non-trailing comments + const dataBuffer = Buffer.isBuffer(code) + ? code + : Buffer.from(code.buffer, code.byteOffset, code.byteLength); + const text = dataBuffer.toString('utf-8'); + const stripped = removeSourceMappingURLFromString(text); + + return stripped === text ? code : Buffer.from(stripped, 'utf-8'); +} + +function removeSourceMappingURLFromString(code: string): string { + if (!code.includes(SOURCEMAP_COMMENT_PREFIX)) { + return code; + } + const result: string[] = []; + let lastCopiedIndex = 0; let i = 0; const len = code.length; @@ -30,7 +108,6 @@ export function removeSourceMappingURL(code: string): string { let currentState: 'normal' | 'string_double' | 'string_single' | 'template' | 'comment_block' | 'comment_line' = 'normal'; - let isStrippingComment = false; while (i < len) { const char = code[i]; @@ -39,20 +116,19 @@ export function removeSourceMappingURL(code: string): string { if (currentState === 'normal') { if (char === '/' && nextChar === '*') { currentState = 'comment_block'; - result.push('/*'); i += 2; continue; } if (char === '/' && nextChar === '/') { // Detect if the comment is escaped (e.g. inside a regex literal like `/\/\/#/`). let isEscaped = false; - let prevIdx = result.length - 1; - while (prevIdx >= 0 && /\s/.test(result[prevIdx])) { + let prevIdx = i - 1; + while (prevIdx >= 0 && /\s/.test(code[prevIdx])) { prevIdx--; } - if (prevIdx >= 0 && result[prevIdx] === '\\') { + if (prevIdx >= 0 && code[prevIdx] === '\\') { let bsCount = 0; - while (prevIdx >= 0 && result[prevIdx] === '\\') { + while (prevIdx >= 0 && code[prevIdx] === '\\') { bsCount++; prevIdx--; } @@ -61,35 +137,36 @@ export function removeSourceMappingURL(code: string): string { } } - if (!isEscaped && code.startsWith('//# sourceMappingURL=', i)) { - currentState = 'comment_line'; - isStrippingComment = true; - i += 21; // Skip the '//# sourceMappingURL=' prefix + if (!isEscaped && code.startsWith(SOURCEMAP_COMMENT_PREFIX, i)) { + if (i > lastCopiedIndex) { + result.push(code.slice(lastCopiedIndex, i)); + } + // Skip the rest of the comment line up to the newline + i += SOURCEMAP_COMMENT_PREFIX.length; + while (i < len && code[i] !== '\n' && code[i] !== '\r') { + i++; + } + lastCopiedIndex = i; continue; } else { currentState = 'comment_line'; - isStrippingComment = false; - result.push('//'); i += 2; continue; } } if (char === '"') { currentState = 'string_double'; - result.push('"'); i++; continue; } if (char === "'") { currentState = 'string_single'; - result.push("'"); i++; continue; } if (char === '`') { currentState = 'template'; stack.push({ type: 'template', braceDepth: 0 }); - result.push('`'); i++; continue; } @@ -98,7 +175,6 @@ export function removeSourceMappingURL(code: string): string { if (top) { top.braceDepth++; } - result.push('{'); i++; continue; } @@ -110,43 +186,35 @@ export function removeSourceMappingURL(code: string): string { // Exiting a template literal interpolation ${ ... } stack.pop(); currentState = 'template'; - result.push('}'); i++; continue; } } - result.push('}'); i++; continue; } - result.push(char); i++; } else if (currentState === 'string_double') { if (char === '\\') { - result.push(char, nextChar || ''); i += 2; continue; } if (char === '"') { currentState = 'normal'; } - result.push(char); i++; } else if (currentState === 'string_single') { if (char === '\\') { - result.push(char, nextChar || ''); i += 2; continue; } if (char === "'") { currentState = 'normal'; } - result.push(char); i++; } else if (currentState === 'template') { if (char === '\\') { - result.push(char, nextChar || ''); i += 2; continue; } @@ -154,7 +222,6 @@ export function removeSourceMappingURL(code: string): string { // Entering template literal interpolation context currentState = 'normal'; stack.push({ type: 'template', braceDepth: 0 }); - result.push('${'); i += 2; continue; } @@ -162,101 +229,129 @@ export function removeSourceMappingURL(code: string): string { stack.pop(); currentState = 'normal'; } - result.push(char); i++; } else if (currentState === 'comment_block') { if (char === '*' && nextChar === '/') { currentState = 'normal'; - result.push('*/'); i += 2; continue; } - result.push(char); i++; } else if (currentState === 'comment_line') { if (char === '\n' || char === '\r') { currentState = 'normal'; - isStrippingComment = false; - result.push(char); - i++; - continue; - } - if (!isStrippingComment) { - result.push(char); } i++; } } + if (lastCopiedIndex === 0) { + return code; + } + + if (lastCopiedIndex < len) { + result.push(code.slice(lastCopiedIndex)); + } + return result.join(''); } /** - * Finds, resolves, and loads the input sourcemap referenced in the code's trailing - * sourceMappingURL comment, if present. Supports inline base64 data URIs, local absolute - * file URLs, and relative/absolute filesystem paths. + * Extracts the base64 payload from an inline sourcemap data URI line and verifies + * that only trailing whitespace follows the payload. + * + * @returns The base64 payload string if valid and trailing, or `undefined` otherwise. */ -export function loadInputSourceMap(filename: string, code: string): EncodedSourceMap | undefined { - // Locate the last sourceMappingURL comment using lastIndexOf to avoid scanning - // the entire file with a regular expression (significant for large files). - const lastSourceMapIndex = code.lastIndexOf('//# sourceMappingURL='); - if (lastSourceMapIndex === -1) { +function extractTrailingBase64Payload(urlLine: string): string | undefined { + if (!urlLine.startsWith('data:application/json;')) { return undefined; } - const urlLine = code.slice(lastSourceMapIndex + 21); - - // Inline base64-encoded sourcemaps can be extremely large (up to megabytes). - // Parse them without regular expressions to avoid heavy backtracking and allocations. - if (urlLine.startsWith('data:application/json;')) { - const base64StartIndex = urlLine.indexOf('base64,'); - if (base64StartIndex === -1) { - return undefined; - } + const base64StartIndex = urlLine.indexOf('base64,'); + if (base64StartIndex === -1) { + return undefined; + } - const payloadStart = base64StartIndex + 7; - let payloadEnd = urlLine.length; - // Find the first trailing whitespace character that marks the end of the base64 payload. - for (let i = payloadStart; i < urlLine.length; i++) { - const char = urlLine[i]; - if (char === ' ' || char === '\r' || char === '\n' || char === '\t') { - payloadEnd = i; - break; - } + const payloadStart = base64StartIndex + 7; + let payloadEnd = urlLine.length; + // Find the first trailing whitespace character that marks the end of the base64 payload. + for (let i = payloadStart; i < urlLine.length; i++) { + const char = urlLine[i]; + if (char === ' ' || char === '\r' || char === '\n' || char === '\t') { + payloadEnd = i; + break; } + } - // Verify that everything after the base64 payload is trailing whitespace - // to ensure this is a valid trailing sourceMappingURL comment at the end of the file. - for (let i = payloadEnd; i < urlLine.length; i++) { - const char = urlLine[i]; - if (char !== ' ' && char !== '\r' && char !== '\n' && char !== '\t') { - return undefined; - } + // Verify that everything after the base64 payload is trailing whitespace + // to ensure this is a valid trailing sourceMappingURL comment at the end of the file. + for (let i = payloadEnd; i < urlLine.length; i++) { + const char = urlLine[i]; + if (char !== ' ' && char !== '\r' && char !== '\n' && char !== '\t') { + return undefined; } + } - try { - // Extract the base64 payload and decode it directly into binary memory. - const base64Content = urlLine.slice(payloadStart, payloadEnd); + return urlLine.slice(payloadStart, payloadEnd); +} - return JSON.parse(Buffer.from(base64Content, 'base64').toString('utf-8')) as EncodedSourceMap; - } catch { - return undefined; - } +/** + * Extracts the URL from an external sourcemap comment line and verifies + * that only trailing whitespace follows the URL. + * + * @returns The URL string if valid and trailing, or `undefined` otherwise. + */ +function extractTrailingUrl(urlLine: string): string | undefined { + if (urlLine.startsWith('data:')) { + return undefined; } - // Non-inline sourcemap comments (always small, typically < 200 characters). - const urlMatch = /^([^\r\n\s]+)/.exec(urlLine); + const urlMatch = /^([^\r\n\s'"`]+)/.exec(urlLine); if (!urlMatch) { return undefined; } - const url = urlMatch[1]; - const remaining = urlLine.slice(url.length); - // Verify there is only whitespace after the URL to the end of the file. + const remaining = urlLine.slice(urlMatch[1].length); if (!/^\s*$/.test(remaining)) { return undefined; } + return urlMatch[1]; +} + +/** + * Checks whether a `//# sourceMappingURL=` URL line snippet represents a valid trailing comment at the end of the file. + */ +export function isTrailingSourceMapComment(urlLine: string): boolean { + return ( + extractTrailingBase64Payload(urlLine) !== undefined || extractTrailingUrl(urlLine) !== undefined + ); +} + +/** + * Resolves and loads the input sourcemap referenced in a `//# sourceMappingURL=` URL line snippet. + * Supports inline base64 data URIs, local absolute file URLs, and relative/absolute filesystem paths. + */ +export function loadInputSourceMapFromUrl( + filename: string, + urlLine: string, +): EncodedSourceMap | undefined { + // Inline base64-encoded sourcemaps can be extremely large (up to megabytes). + // Parse them without regular expressions to avoid heavy backtracking and allocations. + const base64Payload = extractTrailingBase64Payload(urlLine); + if (base64Payload !== undefined) { + try { + return JSON.parse(Buffer.from(base64Payload, 'base64').toString('utf-8')) as EncodedSourceMap; + } catch { + return undefined; + } + } + + const url = extractTrailingUrl(urlLine); + if (!url) { + return undefined; + } + if (url.startsWith('file://')) { // Local absolute file URL scheme. try { @@ -277,3 +372,34 @@ export function loadInputSourceMap(filename: string, code: string): EncodedSourc return undefined; } + +/** + * Finds, resolves, and loads the input sourcemap referenced in the code's trailing + * sourceMappingURL comment, if present. Supports inline base64 data URIs, local absolute + * file URLs, and relative/absolute filesystem paths. + */ +export function loadInputSourceMap(filename: string, code: string): EncodedSourceMap | undefined { + // Locate the last sourceMappingURL comment using lastIndexOf to avoid scanning + // the entire file with a regular expression (significant for large files). + const lastSourceMapIndex = code.lastIndexOf(SOURCEMAP_COMMENT_PREFIX); + if (lastSourceMapIndex === -1) { + return undefined; + } + + if (lastSourceMapIndex > 0) { + // Skip any preceding horizontal whitespace (spaces/tabs) to find the start of the line. + let prevIdx = lastSourceMapIndex - 1; + while (prevIdx >= 0 && (code[prevIdx] === ' ' || code[prevIdx] === '\t')) { + prevIdx--; + } + // Ensure the comment starts at the beginning of a line, preventing false positives within code or strings. + if (prevIdx >= 0 && code[prevIdx] !== '\n' && code[prevIdx] !== '\r') { + return undefined; + } + } + + return loadInputSourceMapFromUrl( + filename, + code.slice(lastSourceMapIndex + SOURCEMAP_COMMENT_PREFIX.length), + ); +} diff --git a/packages/angular/build/src/utils/source-map_spec.ts b/packages/angular/build/src/utils/source-map_spec.ts index 7b424b019403..a5680d61fa4f 100644 --- a/packages/angular/build/src/utils/source-map_spec.ts +++ b/packages/angular/build/src/utils/source-map_spec.ts @@ -6,7 +6,12 @@ * found in the LICENSE file at https://angular.dev/license */ -import { removeSourceMappingURL } from './source-map'; +import { + isTrailingSourceMapComment, + loadInputSourceMap, + loadInputSourceMapFromUrl, + removeSourceMappingURL, +} from './source-map'; describe('removeSourceMappingURL', () => { it('should remove top-level sourcemap comments', () => { @@ -87,4 +92,134 @@ describe('removeSourceMappingURL', () => { const code = '// # sourceMappingURL=main.js.map\n//# sourceMappingURL=main.js.map'; expect(removeSourceMappingURL(code)).toBe('// # sourceMappingURL=main.js.map\n'); }); + + it('should return the exact input string when no sourcemap comment is present', () => { + const code = 'const x = 1;\nconsole.log(x);'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should handle sourcemap comments with CRLF newlines', () => { + const code = 'console.log("hello");\r\n//# sourceMappingURL=main.js.map\r\nconst next = 2;'; + expect(removeSourceMappingURL(code)).toBe('console.log("hello");\r\n\r\nconst next = 2;'); + }); + + describe('with Uint8Array / Buffer inputs', () => { + it('should strip trailing sourcemap comment from Uint8Array buffer', () => { + const buffer = Buffer.from( + 'console.log("hello");\n//# sourceMappingURL=main.js.map', + 'utf-8', + ); + const result = removeSourceMappingURL(buffer); + + expect(Buffer.from(result).toString('utf-8')).toBe('console.log("hello");\n'); + }); + + it('should return exact input buffer when no sourcemap comment is present', () => { + const buffer = Buffer.from('console.log("hello");\nconst x = 1;', 'utf-8'); + const result = removeSourceMappingURL(buffer); + + expect(result).toBe(buffer); + }); + + it('should handle multiple sourcemap comments in a buffer via fallback', () => { + const buffer = Buffer.from( + '//# sourceMappingURL=first.js.map\nconsole.log("mid");\n//# sourceMappingURL=second.js.map', + 'utf-8', + ); + const result = removeSourceMappingURL(buffer); + + expect(Buffer.from(result).toString('utf-8')).toBe('\nconsole.log("mid");\n'); + }); + + it('should not strip sourcemap comments inside template strings in a buffer', () => { + const buffer = Buffer.from('const str = `\n//# sourceMappingURL=inline.js.map\n`;', 'utf-8'); + const result = removeSourceMappingURL(buffer); + + expect(Buffer.from(result).toString('utf-8')).toBe(buffer.toString('utf-8')); + }); + }); +}); + +describe('loadInputSourceMapFromUrl', () => { + it('should decode inline base64 sourcemaps', () => { + const map = { version: 3, sources: ['foo.ts'], mappings: 'AAAA;' }; + const base64 = Buffer.from(JSON.stringify(map)).toString('base64'); + const urlLine = `data:application/json;charset=utf-8;base64,${base64}\n`; + + expect(loadInputSourceMapFromUrl('/src/foo.js', urlLine)).toEqual(map as never); + }); + + it('should return undefined for invalid base64 payloads', () => { + expect( + loadInputSourceMapFromUrl('/src/foo.js', 'data:application/json;base64,invalid!!!'), + ).toBeUndefined(); + }); + + it('should return undefined when no base64 marker is found in data URI', () => { + expect( + loadInputSourceMapFromUrl('/src/foo.js', 'data:application/json;utf8,{}'), + ).toBeUndefined(); + }); +}); + +describe('loadInputSourceMap', () => { + it('should extract and decode sourcemap from source string', () => { + const map = { version: 3, sources: ['foo.ts'], mappings: 'AAAA;' }; + const base64 = Buffer.from(JSON.stringify(map)).toString('base64'); + const code = `console.log("hello");\n//# sourceMappingURL=data:application/json;base64,${base64}\n`; + + expect(loadInputSourceMap('/src/foo.js', code)).toEqual(map as never); + }); + + it('should return undefined when no sourceMappingURL comment is present', () => { + expect(loadInputSourceMap('/src/foo.js', 'console.log("hello");')).toBeUndefined(); + }); + + it('should return undefined for comments inside template strings with code after them', () => { + const map = { version: 3, sources: ['foo.ts'], mappings: 'AAAA;' }; + const base64 = Buffer.from(JSON.stringify(map)).toString('base64'); + const code = `const str = \`\n//# sourceMappingURL=data:application/json;base64,${base64}\n\`;\nconsole.log(str);`; + + expect(loadInputSourceMap('/src/foo.js', code)).toBeUndefined(); + }); + + it('should return undefined for comments inside template strings ending with backticks', () => { + const map = { version: 3, sources: ['foo.ts'], mappings: 'AAAA;' }; + const base64 = Buffer.from(JSON.stringify(map)).toString('base64'); + const code = `const str = \`\n//# sourceMappingURL=data:application/json;base64,${base64}\n\`;`; + + expect(loadInputSourceMap('/src/foo.js', code)).toBeUndefined(); + }); + + it('should return undefined for single-line template literals', () => { + const map = { version: 3, sources: ['foo.ts'], mappings: 'AAAA;' }; + const base64 = Buffer.from(JSON.stringify(map)).toString('base64'); + const code = `const str = \`//# sourceMappingURL=data:application/json;base64,${base64}\`;`; + + expect(loadInputSourceMap('/src/foo.js', code)).toBeUndefined(); + }); +}); + +describe('isTrailingSourceMapComment', () => { + it('should return true for valid inline data URIs at end of file', () => { + const map = { version: 3, sources: ['foo.ts'], mappings: 'AAAA;' }; + const base64 = Buffer.from(JSON.stringify(map)).toString('base64'); + const urlLine = `data:application/json;charset=utf-8;base64,${base64}\n`; + + expect(isTrailingSourceMapComment(urlLine)).toBe(true); + }); + + it('should return true for external sourcemap URLs at end of file', () => { + expect(isTrailingSourceMapComment('main.js.map\n')).toBe(true); + expect(isTrailingSourceMapComment('main.js.map')).toBe(true); + }); + + it('should return false when followed by non-whitespace characters', () => { + expect(isTrailingSourceMapComment('main.js.map\n`;\nconsole.log("hi");')).toBe(false); + expect(isTrailingSourceMapComment('main.js.map`;')).toBe(false); + }); + + it('should return false for invalid data URI format', () => { + expect(isTrailingSourceMapComment('data:application/json;utf8,{}')).toBe(false); + }); }); diff --git a/packages/angular/build/src/utils/test-files.ts b/packages/angular/build/src/utils/test-files.ts index d5f0edb2815d..6790d007e220 100644 --- a/packages/angular/build/src/utils/test-files.ts +++ b/packages/angular/build/src/utils/test-files.ts @@ -17,21 +17,18 @@ import { emitFilesToDisk } from '../tools/esbuild/utils'; * This function handles both in-memory and on-disk files, creating subdirectories * as needed. * - * @param files A map of file paths to `ResultFile` objects, representing the build output. + * @param files A collection of `ResultFile` objects, representing the build output. * @param testDir The absolute path to the directory where the files should be written. */ -export async function writeTestFiles( - files: Record, - testDir: string, -): Promise { +export async function writeTestFiles(files: readonly ResultFile[], testDir: string): Promise { const directoryExists = new Set(); // Writes the test related output files to disk and ensures the containing directories are present - await emitFilesToDisk(Object.entries(files), async ([filePath, file]) => { + await emitFilesToDisk(files, async (file) => { if (file.type !== BuildOutputFileType.Browser && file.type !== BuildOutputFileType.Media) { return; } - const fullFilePath = path.join(testDir, filePath); + const fullFilePath = path.join(testDir, file.path); // Ensure output subdirectories exist const fileBasePath = path.dirname(fullFilePath); diff --git a/packages/angular/build/src/utils/watcher.ts b/packages/angular/build/src/utils/watcher.ts new file mode 100644 index 000000000000..be38151f7a48 --- /dev/null +++ b/packages/angular/build/src/utils/watcher.ts @@ -0,0 +1,755 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type * as ParcelWatcher from '@parcel/watcher'; +import type * as Chokidar from 'chokidar'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import picomatch from 'picomatch'; +import { shouldWatchRoot } from './environment-options'; +import { toPosixPath } from './path'; + +export class ChangedFiles { + readonly added = new Set(); + readonly modified = new Set(); + readonly removed = new Set(); + + get all(): string[] { + return Array.from(new Set([...this.added, ...this.modified, ...this.removed])); + } + + toDebugString(): string { + const content = { + added: Array.from(this.added), + modified: Array.from(this.modified), + removed: Array.from(this.removed), + }; + + return JSON.stringify(content, null, 2); + } +} + +export interface BuildWatcher extends AsyncIterableIterator { + add(paths: string | readonly string[]): void; + remove(paths: string | readonly string[]): void; + close(): Promise; +} + +export interface WatcherOptions { + polling?: boolean; + interval?: number; + ignored?: string[]; + followSymlinks?: boolean; + cwd?: string; +} + +// Watch workspace for package manager changes +const packageWatchFiles = [ + // manifest can affect module resolution + 'package.json', + // npm lock file + 'package-lock.json', + // pnpm lock file + 'pnpm-lock.yaml', + // yarn lock file including Yarn PnP manifest files (https://yarnpkg.com/advanced/pnp-spec/) + 'yarn.lock', + '.pnp.cjs', + '.pnp.data.json', +]; + +export interface SetupWatcherOptions { + workspaceRoot: string; + projectRoot: string; + outputPath: string; + cacheOptions: { basePath: string; localBasePath?: string }; + poll?: number; + preserveSymlinks?: boolean; + signal?: AbortSignal; + watchFiles?: Iterable; +} + +/** + * Sets up and initializes a file watcher with proper ignore patterns for build outputs and caches. + */ +export async function setupWatcher(options: SetupWatcherOptions): Promise { + const { + workspaceRoot, + projectRoot, + outputPath, + cacheOptions, + poll, + preserveSymlinks, + signal, + watchFiles, + } = options; + + const normalizedOutputBase = toPosixPath(outputPath); + const normalizedCacheBase = toPosixPath(cacheOptions.basePath); + const ignored: string[] = [ + // Ignore the output and cache paths to avoid infinite rebuild cycles + normalizedOutputBase, + `${normalizedOutputBase}/**`, + normalizedCacheBase, + `${normalizedCacheBase}/**`, + `${toPosixPath(workspaceRoot)}/**/.*/**`, + ]; + + if (cacheOptions.localBasePath && cacheOptions.localBasePath !== cacheOptions.basePath) { + const normalizedLocalCacheBase = toPosixPath(cacheOptions.localBasePath); + ignored.push(normalizedLocalCacheBase, `${normalizedLocalCacheBase}/**`); + } + + if (shouldWatchRoot && !preserveSymlinks) { + // Ignore all node modules directories to avoid excessive file watchers. + // Package changes are handled below by watching manifest and lock files. + // NOTE: this is not enabled when preserveSymlinks is true as this would break `npm link` usages. + ignored.push('**/node_modules/**'); + } + + const watcher = await createWatcher({ + polling: typeof poll === 'number', + interval: poll, + followSymlinks: preserveSymlinks, + ignored, + cwd: workspaceRoot, + }); + + // Setup abort support + if (signal) { + const onAbort = () => void watcher.close(); + signal.addEventListener('abort', onAbort, { once: true }); + const originalClose = watcher.close.bind(watcher); + watcher.close = async () => { + signal.removeEventListener('abort', onAbort); + await originalClose(); + }; + } + + // Watch the entire project root if 'NG_BUILD_WATCH_ROOT' environment variable is set + if (shouldWatchRoot) { + if (!preserveSymlinks) { + watcher.add( + packageWatchFiles + .map((file) => path.join(workspaceRoot, file)) + .filter((file) => fs.existsSync(file)), + ); + } + + watcher.add(projectRoot); + } + + if (watchFiles) { + watcher.add(Array.isArray(watchFiles) ? watchFiles : Array.from(watchFiles)); + } + + return watcher; +} + +/** + * Probes the filesystem at the specified target directory to determine whether it is case-sensitive. + */ +function isFileSystemCaseSensitive(targetDir: string = process.cwd()): boolean { + try { + const resolved = path.resolve(targetDir); + if (!fs.existsSync(resolved)) { + return process.platform !== 'win32' && process.platform !== 'darwin'; + } + + // Invert the casing of the target directory path. + const altCase = + resolved === resolved.toLowerCase() ? resolved.toUpperCase() : resolved.toLowerCase(); + + // If the path contains no alphabetic characters (e.g. root '/'), invert-casing + // produces the exact same string. Fall back to platform-specific defaults in this case. + if (resolved === altCase) { + return process.platform !== 'win32' && process.platform !== 'darwin'; + } + + // If both the original path and the inverted-casing path exist on disk, + // the filesystem is case-insensitive (returns false). + return !fs.existsSync(altCase); + } catch { + // If an error occurs (e.g., permission denied), default to the platform-specific + // behavior (case-insensitive on Windows/macOS, sensitive on Linux/Unix). + return process.platform !== 'win32' && process.platform !== 'darwin'; + } +} + +/** + * Normalizes a file system path string to POSIX format (forward slashes '/') + * and strips trailing slashes (except root '/' or Windows drive root 'C:/'). + */ +export function toPosixPathNormalized(pathString: string): string { + let posixPath = toPosixPath(pathString); + if (posixPath.length > 1 && posixPath.endsWith('/') && !/^[a-zA-Z]:\/$/.test(posixPath)) { + posixPath = posixPath.slice(0, -1); + } + + return posixPath; +} + +/** + * Returns a lookup key for set lookups and matching, lowercasing on case-insensitive file systems. + */ +function toLookupKey(posixPath: string, isCaseSensitive: boolean): string { + return isCaseSensitive ? posixPath : posixPath.toLowerCase(); +} + +/** + * Returns the parent directory of a normalized POSIX path, correctly handling Windows drive roots. + */ +export function getDirectoryPath(posixPath: string): string { + const lastSlash = posixPath.lastIndexOf('/'); + if (lastSlash === -1) { + return '.'; + } + const dir = posixPath.slice(0, lastSlash); + if (dir === '' || dir.endsWith(':')) { + return dir + '/'; + } + + return dir; +} + +/** + * Determines whether a file path lookup key or any of its parent directories are present in watchedFiles. + */ +function isPathWatched(fileLookupKey: string, watchedFiles: Set): boolean { + if (watchedFiles.has(fileLookupKey)) { + return true; + } + + let current = fileLookupKey; + while (true) { + const parent = getDirectoryPath(current); + if (parent === current) { + break; + } + if (watchedFiles.has(parent)) { + return true; + } + current = parent; + } + + return false; +} + +class WatcherQueue { + private readonly nextQueue: ((value?: ChangedFiles) => void)[] = []; + private currentChangedFiles: ChangedFiles | undefined; + private isClosed = false; + private timeoutId: NodeJS.Timeout | undefined; + private firstChangeTime: number | undefined; + + constructor( + private readonly debounceMs = 100, + private readonly maxWaitMs = 500, + ) {} + + addChange(type: 'added' | 'modified' | 'removed', file: string): void { + if (this.isClosed) { + return; + } + + const changedFiles = (this.currentChangedFiles ??= new ChangedFiles()); + changedFiles[type].add(file); + this.scheduleFlush(); + } + + addChanges( + changes: ReadonlyArray<{ type: 'added' | 'modified' | 'removed'; file: string }>, + ): void { + if (this.isClosed || changes.length === 0) { + return; + } + + const changedFiles = (this.currentChangedFiles ??= new ChangedFiles()); + for (const { type, file } of changes) { + changedFiles[type].add(file); + } + this.scheduleFlush(); + } + + private scheduleFlush(): void { + const now = Date.now(); + const firstChangeTime = (this.firstChangeTime ??= now); + + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + + const elapsed = now - firstChangeTime; + const remainingMaxWait = Math.max(0, this.maxWaitMs - elapsed); + const delay = Math.min(this.debounceMs, remainingMaxWait); + + this.timeoutId = setTimeout(() => { + this.timeoutId = undefined; + this.flush(); + }, delay); + } + + private flush(): void { + this.firstChangeTime = undefined; + if ( + this.currentChangedFiles && + this.currentChangedFiles.all.length > 0 && + this.nextQueue.length > 0 + ) { + const next = this.nextQueue.shift(); + if (next) { + const result = this.currentChangedFiles; + this.currentChangedFiles = undefined; + next(result); + } + } + } + + async next(): Promise> { + if ( + this.currentChangedFiles && + this.currentChangedFiles.all.length > 0 && + this.nextQueue.length === 0 && + !this.timeoutId + ) { + const result = { value: this.currentChangedFiles }; + this.currentChangedFiles = undefined; + + return result; + } + + if (this.isClosed) { + return { done: true, value: undefined as unknown as ChangedFiles }; + } + + return new Promise((resolve) => { + this.nextQueue.push((value) => + resolve(value ? { value } : { done: true, value: undefined as unknown as ChangedFiles }), + ); + }); + } + + close(): void { + if (this.isClosed) { + return; + } + + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + this.firstChangeTime = undefined; + + this.isClosed = true; + this.currentChangedFiles = undefined; + + let next; + while ((next = this.nextQueue.shift()) !== undefined) { + next(); + } + } +} + +export async function createWatcher(options?: WatcherOptions): Promise { + if (options?.polling || options?.followSymlinks) { + return createChokidarWatcher(options); + } + + try { + const parcelWatcher = await import('@parcel/watcher'); + + return await createParcelWatcher(options, parcelWatcher); + } catch { + return createChokidarWatcher(options); + } +} + +/** + * Checks whether a file path is located inside a parent directory. + * + * Input Expectations: + * - Both `file` and `dir` must be normalized POSIX-style paths (using forward slashes '/'). + * - Both paths must share the same casing normalization (e.g., lowercased on case-insensitive file systems). + */ +export function isPathInside(file: string, dir: string): boolean { + if (file === dir) { + return false; + } + + const dirWithSlash = dir.endsWith('/') ? dir : dir + '/'; + + return file.startsWith(dirWithSlash); +} + +class ParcelExternalManager { + private readonly extraSubscriptions = new Map(); + private readonly pendingSubscriptions = new Map< + string, + Promise + >(); + private readonly externalDirFiles = new Map }>(); + + constructor( + private readonly parcelWatcher: typeof ParcelWatcher, + private readonly options: WatcherOptions | undefined, + private readonly rootDirLookupKey: string, + private readonly handleEvents: (events: ParcelWatcher.Event[]) => void, + ) {} + + async ensureWatched(posixPath: string, lookupKey: string): Promise { + if (isPathInside(lookupKey, this.rootDirLookupKey) || lookupKey === this.rootDirLookupKey) { + return; + } + + const dirPath = getDirectoryPath(posixPath); + const dirKey = getDirectoryPath(lookupKey); + let dirEntry = this.externalDirFiles.get(dirKey); + if (!dirEntry) { + dirEntry = { dirPath, files: new Set() }; + this.externalDirFiles.set(dirKey, dirEntry); + } + dirEntry.files.add(lookupKey); + + await this.ensureDirWatched(dirPath, dirKey); + } + + removeFile(lookupKey: string): void { + if (isPathInside(lookupKey, this.rootDirLookupKey) || lookupKey === this.rootDirLookupKey) { + return; + } + + const dirKey = getDirectoryPath(lookupKey); + const dirEntry = this.externalDirFiles.get(dirKey); + if (dirEntry) { + dirEntry.files.delete(lookupKey); + if (dirEntry.files.size === 0) { + this.externalDirFiles.delete(dirKey); + const sub = this.extraSubscriptions.get(dirKey); + if (sub) { + this.extraSubscriptions.delete(dirKey); + sub.unsubscribe().catch(() => {}); + + for (const [remainingDirKey, remainingDirEntry] of this.externalDirFiles.entries()) { + if (!this.isCoveredByExistingExternal(remainingDirKey)) { + this.ensureDirWatched(remainingDirEntry.dirPath, remainingDirKey).catch(() => {}); + } + } + } + } + } + } + + async close(): Promise { + try { + if (this.pendingSubscriptions.size > 0) { + await Promise.allSettled(Array.from(this.pendingSubscriptions.values())); + } + if (this.extraSubscriptions.size > 0) { + await Promise.allSettled( + Array.from(this.extraSubscriptions.values()).map((sub) => sub.unsubscribe()), + ); + } + } finally { + this.extraSubscriptions.clear(); + this.pendingSubscriptions.clear(); + this.externalDirFiles.clear(); + } + } + + private isCoveredByExistingExternal(dirLookupKey: string): boolean { + for (const existingDir of this.extraSubscriptions.keys()) { + if (dirLookupKey === existingDir || isPathInside(dirLookupKey, existingDir)) { + return true; + } + } + for (const pendingDir of this.pendingSubscriptions.keys()) { + if (dirLookupKey === pendingDir || isPathInside(dirLookupKey, pendingDir)) { + return true; + } + } + + return false; + } + + private async ensureDirWatched(dirPath: string, dirKey: string): Promise { + if (this.isCoveredByExistingExternal(dirKey)) { + return; + } + + const subPromise = this.parcelWatcher.subscribe( + dirPath, + (err, events) => { + if (!err) { + this.handleEvents(events); + } + }, + { + ignore: this.options?.ignored, + }, + ); + + this.pendingSubscriptions.set(dirKey, subPromise); + + try { + const sub = await subPromise; + if (this.externalDirFiles.has(dirKey) && !this.isCoveredByExistingExternal(dirKey)) { + this.extraSubscriptions.set(dirKey, sub); + + // Subsume any nested child subscriptions that are now covered by this parent subscription + for (const [childDir, childSub] of this.extraSubscriptions.entries()) { + if (childDir !== dirKey && isPathInside(childDir, dirKey)) { + this.extraSubscriptions.delete(childDir); + childSub.unsubscribe().catch(() => {}); + } + } + } else { + sub.unsubscribe().catch(() => {}); + } + } catch { + // Ignore subscription errors for missing or restricted external directories + } finally { + this.pendingSubscriptions.delete(dirKey); + } + } +} + +async function createParcelWatcher( + options: WatcherOptions | undefined, + parcelWatcher: typeof ParcelWatcher, +): Promise { + const watchedFiles = new Set(); + const queue = new WatcherQueue(); + + const isCaseSensitive = isFileSystemCaseSensitive(options?.cwd); + const rootDirPosix = toPosixPathNormalized(options?.cwd ?? process.cwd()); + const rootDirLookupKey = toLookupKey(rootDirPosix, isCaseSensitive); + const initTime = Date.now(); + + const handleEvents = (events: ParcelWatcher.Event[]) => { + const changes: { type: 'added' | 'modified' | 'removed'; file: string }[] = []; + for (const event of events) { + const posixPath = toPosixPathNormalized(event.path); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!isPathWatched(lookupKey, watchedFiles)) { + continue; + } + + if (event.type !== 'delete') { + const stat = fs.statSync(event.path, { throwIfNoEntry: false }); + // Ignore historical events from before watcher initialization, but allow a 1000 ms window + // to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second + // mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes. + if (stat && stat.mtimeMs < initTime - 1000) { + continue; + } + } + + const type = + event.type === 'create' ? 'added' : event.type === 'delete' ? 'removed' : 'modified'; + changes.push({ type, file: event.path }); + } + + if (changes.length > 0) { + queue.addChanges(changes); + } + }; + + const subscription = await parcelWatcher.subscribe( + rootDirPosix, + (err, events) => { + if (!err) { + handleEvents(events); + } + }, + { + ignore: options?.ignored, + }, + ); + + const externalManager = new ParcelExternalManager( + parcelWatcher, + options, + rootDirLookupKey, + handleEvents, + ); + + const buildWatcher: BuildWatcher = { + [Symbol.asyncIterator]() { + return this; + }, + + next() { + return queue.next(); + }, + + add(paths) { + const targets = typeof paths === 'string' ? [paths] : paths; + for (const file of targets) { + const posixPath = toPosixPathNormalized(file); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!watchedFiles.has(lookupKey)) { + watchedFiles.add(lookupKey); + void externalManager.ensureWatched(posixPath, lookupKey); + } + } + }, + + remove(paths) { + const targets = typeof paths === 'string' ? [paths] : paths; + for (const file of targets) { + const posixPath = toPosixPathNormalized(file); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (watchedFiles.delete(lookupKey)) { + externalManager.removeFile(lookupKey); + } + } + }, + + async close() { + try { + if (subscription) { + await subscription.unsubscribe(); + } + await externalManager.close(); + } finally { + queue.close(); + } + }, + }; + + return buildWatcher; +} + +async function createChokidarWatcher( + options?: WatcherOptions, + chokidarModule?: typeof Chokidar, +): Promise { + const chokidar = chokidarModule ?? (await import('chokidar')); + const watchedFiles = new Set(); + + let queue: WatcherQueue; + if (options?.polling) { + const pollingInterval = options.interval ?? 100; + const debounceMs = Math.min(250, Math.max(100, Math.ceil(pollingInterval * 1.5))); + const maxWaitMs = Math.max(500, debounceMs * 3); + queue = new WatcherQueue(debounceMs, maxWaitMs); + } else { + queue = new WatcherQueue(); + } + + const rootDir = options?.cwd ?? process.cwd(); + const isCaseSensitive = isFileSystemCaseSensitive(rootDir); + const rootDirPosix = toPosixPathNormalized(rootDir); + const rootDirLookupKey = toLookupKey(rootDirPosix, isCaseSensitive); + + const ignored = options?.ignored?.map((pattern) => { + if (/[*?[\]{}()]/.test(pattern)) { + const isMatch = picomatch(pattern, { dot: true }); + + return (filePath: string) => isMatch(toPosixPathNormalized(filePath)); + } + + return { path: toPosixPathNormalized(pattern), recursive: true }; + }); + + const watcher = chokidar.watch(rootDir, { + ignoreInitial: true, + ignored, + followSymlinks: options?.followSymlinks, + usePolling: !!options?.polling, + interval: options?.interval, + }); + + const initTime = Date.now(); + + // Wait for the watcher to complete its initial filesystem scan before returning. + // With `ignoreInitial: true`, any file visited during the initial scan is treated as the initial baseline + // and will not emit 'add' or 'change' events. Awaiting 'ready' ensures that rapid file modifications + // made right after watcher setup (e.g. in rebuild tests) are not swallowed as initial files. + await once(watcher, 'ready'); + + const handleEvent = (type: 'added' | 'modified' | 'removed', rawPath: string) => { + const posixPath = toPosixPathNormalized(rawPath); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!isPathWatched(lookupKey, watchedFiles)) { + return; + } + + if (type !== 'removed') { + const stat = fs.statSync(rawPath, { throwIfNoEntry: false }); + // Ignore historical events from before watcher initialization, but allow a 1000 ms window + // to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second + // mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes. + if (stat && stat.mtimeMs < initTime - 1000) { + return; + } + } + + queue.addChange(type, rawPath); + }; + + watcher.on('add', (path) => handleEvent('added', path)); + watcher.on('change', (path) => handleEvent('modified', path)); + watcher.on('unlink', (path) => handleEvent('removed', path)); + + const buildWatcher: BuildWatcher = { + [Symbol.asyncIterator]() { + return this; + }, + + next() { + return queue.next(); + }, + + add(paths) { + const targets = typeof paths === 'string' ? [paths] : paths; + const newPaths: string[] = []; + for (const p of targets) { + const posixPath = toPosixPathNormalized(p); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!watchedFiles.has(lookupKey)) { + watchedFiles.add(lookupKey); + if (!isPathInside(lookupKey, rootDirLookupKey) && lookupKey !== rootDirLookupKey) { + newPaths.push(posixPath); + } + } + } + if (newPaths.length > 0) { + watcher.add(newPaths); + } + }, + + remove(paths) { + const targets = typeof paths === 'string' ? [paths] : paths; + const removePaths: string[] = []; + for (const p of targets) { + const posixPath = toPosixPathNormalized(p); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (watchedFiles.has(lookupKey)) { + watchedFiles.delete(lookupKey); + if (!isPathInside(lookupKey, rootDirLookupKey) && lookupKey !== rootDirLookupKey) { + removePaths.push(posixPath); + } + } + } + if (removePaths.length > 0) { + watcher.unwatch(removePaths); + } + }, + + async close() { + try { + await watcher.close(); + } finally { + queue.close(); + } + }, + }; + + return buildWatcher; +} diff --git a/packages/angular/build/src/utils/watcher_spec.ts b/packages/angular/build/src/utils/watcher_spec.ts new file mode 100644 index 000000000000..e051c44bd3f2 --- /dev/null +++ b/packages/angular/build/src/utils/watcher_spec.ts @@ -0,0 +1,575 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { setTimeout } from 'node:timers/promises'; +import { + type BuildWatcher, + ChangedFiles, + createWatcher, + getDirectoryPath, + isPathInside, + setupWatcher, + toPosixPathNormalized, +} from './watcher'; + +describe('Watcher', () => { + describe('toPosixPathNormalized', () => { + it('should strip trailing slashes for standard directories', () => { + expect(toPosixPathNormalized('/src/app/')).toBe('/src/app'); + expect(toPosixPathNormalized('C:/src/app/')).toBe('C:/src/app'); + }); + + it('should preserve single root slash', () => { + expect(toPosixPathNormalized('/')).toBe('/'); + }); + + it('should preserve trailing slash for Windows drive root', () => { + expect(toPosixPathNormalized('C:/')).toBe('C:/'); + expect(toPosixPathNormalized('c:/')).toBe('c:/'); + }); + }); + + describe('getDirectoryPath', () => { + it('should return parent directory for POSIX paths', () => { + expect(getDirectoryPath('/src/app/main.ts')).toBe('/src/app'); + expect(getDirectoryPath('/src/app')).toBe('/src'); + expect(getDirectoryPath('/src')).toBe('/'); + expect(getDirectoryPath('/')).toBe('/'); + }); + + it('should correctly handle Windows drive roots', () => { + expect(getDirectoryPath('C:/src/app/main.ts')).toBe('C:/src/app'); + expect(getDirectoryPath('C:/src')).toBe('C:/'); + expect(getDirectoryPath('C:/')).toBe('C:/'); + expect(getDirectoryPath('c:/')).toBe('c:/'); + }); + + it('should return dot for relative paths without slash', () => { + expect(getDirectoryPath('main.ts')).toBe('.'); + }); + }); + + describe('isPathInside', () => { + it('should return true for a file inside a directory', () => { + expect(isPathInside('/src/app/main.ts', '/src/app')).toBeTrue(); + }); + + it('should return false when file and dir are identical', () => { + expect(isPathInside('/src/app', '/src/app')).toBeFalse(); + }); + + it('should return false for sibling directories with matching prefix', () => { + expect(isPathInside('/src/app-other/main.ts', '/src/app')).toBeFalse(); + }); + + it('should handle Windows drive letters on the same drive', () => { + expect(isPathInside('c:/src/app/main.ts', 'c:/src/app')).toBeTrue(); + }); + + it('should return false for Windows drive letters on different drives', () => { + expect(isPathInside('d:/src/app/main.ts', 'c:/src/app')).toBeFalse(); + }); + + it('should handle root directory correctly', () => { + expect(isPathInside('/src/main.ts', '/')).toBeTrue(); + }); + + it('should handle Windows drive root directory correctly', () => { + expect(isPathInside('c:/src/main.ts', 'c:/')).toBeTrue(); + }); + }); + + describe('ChangedFiles', () => { + it('should track added, modified, and removed files', () => { + const changes = new ChangedFiles(); + changes.added.add('/src/app.component.ts'); + changes.modified.add('/src/main.ts'); + changes.removed.add('/src/old.ts'); + + expect(changes.all).toEqual(['/src/app.component.ts', '/src/main.ts', '/src/old.ts']); + }); + + it('should deduplicate files present in multiple sets in .all', () => { + const changes = new ChangedFiles(); + changes.added.add('/src/main.ts'); + changes.modified.add('/src/main.ts'); + + expect(changes.all).toEqual(['/src/main.ts']); + }); + + it('should format debug string correctly', () => { + const changes = new ChangedFiles(); + changes.modified.add('/src/main.ts'); + + const debug = JSON.parse(changes.toDebugString()); + expect(debug).toEqual({ + added: [], + modified: ['/src/main.ts'], + removed: [], + }); + }); + }); + + describe('setupWatcher', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'setup-watcher-spec-'))); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should setup watcher with watchFiles and close on abort signal', async () => { + const abortController = new AbortController(); + const testFile = path.join(tempDir, 'main.ts'); + const watcher = await setupWatcher({ + workspaceRoot: tempDir, + projectRoot: tempDir, + outputPath: path.join(tempDir, 'dist'), + cacheOptions: { basePath: path.join(tempDir, '.cache') }, + watchFiles: [testFile], + signal: abortController.signal, + }); + + expect(watcher).toBeDefined(); + + const closeSpy = spyOn(watcher, 'close').and.callThrough(); + abortController.abort(); + + expect(closeSpy).toHaveBeenCalled(); + await watcher.close(); + }); + + it('should remove abort listener when watcher is closed', async () => { + const abortController = new AbortController(); + const removeSpy = spyOn(abortController.signal, 'removeEventListener').and.callThrough(); + const watcher = await setupWatcher({ + workspaceRoot: tempDir, + projectRoot: tempDir, + outputPath: path.join(tempDir, 'dist'), + cacheOptions: { basePath: path.join(tempDir, '.cache') }, + signal: abortController.signal, + }); + + await watcher.close(); + expect(removeSpy).toHaveBeenCalledWith('abort', jasmine.any(Function)); + }); + }); + + describe('createWatcher', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'watcher-spec-'))); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should instantiate and close watcher without error', async () => { + const watcher = await createWatcher({ cwd: tempDir }); + expect(watcher).toBeDefined(); + + watcher.add(path.join(tempDir, 'main.ts')); + watcher.remove(path.join(tempDir, 'main.ts')); + + await watcher.close(); + }); + + it('should support array of paths in add and remove', async () => { + const watcher = await createWatcher({ cwd: tempDir }); + const file1 = path.join(tempDir, 'a.ts'); + const file2 = path.join(tempDir, 'b.ts'); + + watcher.add([file1, file2]); + watcher.remove([file1, file2]); + + await watcher.close(); + }); + + it('should support polling option', async () => { + const watcher = await createWatcher({ polling: true, interval: 100, cwd: tempDir }); + expect(watcher).toBeDefined(); + + watcher.add(path.join(tempDir, 'main.ts')); + await watcher.close(); + }); + + it('should emit changes when a watched file is modified (chokidar polling)', async () => { + const testFile = path.join(tempDir, 'test.txt'); + fs.writeFileSync(testFile, 'initial'); + + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(testFile); + + // Wait a short moment for watcher setup and mtime tick + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + // Trigger change + fs.writeFileSync(testFile, 'updated'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.length).toBeGreaterThan(0); + + await watcher.close(); + }, 10000); + + it('should preserve original path character casing in emitted changes', async () => { + const casedFile = path.join(tempDir, 'App.Component.ts'); + fs.writeFileSync(casedFile, 'initial'); + + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(casedFile); + + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(casedFile, 'updated'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + const emittedFiles = result.value?.all ?? []; + expect(emittedFiles.some((f: string) => f.includes('App.Component.ts'))).toBeTrue(); + + await watcher.close(); + }, 10000); + + it('should emit changes when watching a directory containing modified files', async () => { + const subDir = path.join(tempDir, 'sub'); + fs.mkdirSync(subDir); + const testFile = path.join(subDir, 'nested.txt'); + fs.writeFileSync(testFile, 'initial'); + + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(subDir); + + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(testFile, 'updated'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.length).toBeGreaterThan(0); + + await watcher.close(); + }, 10000); + + it('should support watching paths outside cwd', async () => { + const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-')); + const externalFile = path.join(externalDir, 'external.txt'); + fs.writeFileSync(externalFile, 'initial'); + + try { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(externalFile); + + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(externalFile, 'updated'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('external.txt'))).toBeTrue(); + + await watcher.close(); + } finally { + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should handle adding multiple external files in the same directory concurrently', async () => { + const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-')); + const file1 = path.join(externalDir, 'file1.txt'); + const file2 = path.join(externalDir, 'file2.txt'); + fs.writeFileSync(file1, 'initial1'); + fs.writeFileSync(file2, 'initial2'); + + try { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add([file1, file2]); + + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + let nextPromise = iterator.next(); + fs.writeFileSync(file1, 'updated1'); + let result = await nextPromise; + expect(result.value?.all.some((f: string) => f.includes('file1.txt'))).toBeTrue(); + + nextPromise = iterator.next(); + fs.writeFileSync(file2, 'updated2'); + result = await nextPromise; + expect(result.value?.all.some((f: string) => f.includes('file2.txt'))).toBeTrue(); + + await watcher.close(); + } finally { + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should clean up external subscriptions when all external files in a directory are removed', async () => { + const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-')); + const file1 = path.join(externalDir, 'file1.txt'); + const file2 = path.join(externalDir, 'file2.txt'); + fs.writeFileSync(file1, 'initial1'); + fs.writeFileSync(file2, 'initial2'); + + try { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add([file1, file2]); + + await setTimeout(100); + + // Remove files from watcher + watcher.remove(file1); + watcher.remove(file2); + + await watcher.close(); + } finally { + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }); + + it('should handle nested external directories without creating duplicate subscriptions', async () => { + const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-')); + const subDir = path.join(externalDir, 'sub'); + fs.mkdirSync(subDir); + const parentFile = path.join(externalDir, 'parent.txt'); + const childFile = path.join(subDir, 'child.txt'); + fs.writeFileSync(parentFile, 'initial-parent'); + fs.writeFileSync(childFile, 'initial-child'); + + try { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(parentFile); + watcher.add(childFile); + + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(childFile, 'updated-child'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('child.txt'))).toBeTrue(); + + await watcher.close(); + } finally { + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should subscribe to subsumed external child directory when parent external subscription is removed', async () => { + const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-')); + const subDir = path.join(externalDir, 'sub'); + fs.mkdirSync(subDir); + const parentFile = path.join(externalDir, 'parent.txt'); + const childFile = path.join(subDir, 'child.txt'); + fs.writeFileSync(parentFile, 'initial-parent'); + fs.writeFileSync(childFile, 'initial-child'); + + try { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(parentFile); + watcher.add(childFile); + + await setTimeout(100); + + watcher.remove(parentFile); + + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(childFile, 'updated-child'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('child.txt'))).toBeTrue(); + + await watcher.close(); + } finally { + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should signal completion on close', async () => { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + const iterator = watcher[Symbol.asyncIterator](); + + const nextPromise = iterator.next(); + await watcher.close(); + + const result = await nextPromise; + expect(result.done).toBeTrue(); + }); + + it('should return done immediately if next() is called after close()', async () => { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + await watcher.close(); + + const result = await watcher.next(); + expect(result.done).toBeTrue(); + }); + + it('should ignore stale modifications before initTime and emit changes after initTime (@parcel/watcher)', async () => { + const testFile = path.join(tempDir, 'test.txt'); + fs.writeFileSync(testFile, 'initial'); + + // Small delay to ensure initial mtimeMs is strictly earlier than initTime - 1000 + await setTimeout(1100); + + // Create native @parcel/watcher (polling: false / default) + const watcher = await createWatcher({ cwd: tempDir }); + watcher.add(testFile); + + // Wait a short moment for native watcher setup and kernel event stream initialization + await setTimeout(150); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + // Trigger a change after watcher initialization + fs.writeFileSync(testFile, 'updated'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('test.txt'))).toBeTrue(); + + await watcher.close(); + }, 10000); + + it('should emit changes when a file is deleted and recreated with stabilization delay (@parcel/watcher)', async () => { + const testFile = path.join(tempDir, 'recreate.txt'); + fs.writeFileSync(testFile, 'initial'); + + await setTimeout(50); + + const watcher = await createWatcher({ cwd: tempDir }); + watcher.add(testFile); + + await setTimeout(150); + + const iterator = watcher[Symbol.asyncIterator](); + + // Delete the file + fs.rmSync(testFile); + let result = await iterator.next(); + expect(result.done).toBeFalsy(); + expect(result.value?.removed.size).toBeGreaterThan(0); + + // Brief stabilization delay before recreating to prevent macOS fsevents kernel driver + // from coalescing unlink and create into a single directory event + await setTimeout(150); + + // Recreate the file + fs.writeFileSync(testFile, 'recreated'); + result = await iterator.next(); + expect(result.done).toBeFalsy(); + expect(result.value?.added.size).toBeGreaterThan(0); + + await watcher.close(); + }, 10000); + + it('should ignore changes matching glob patterns in polling mode (chokidar)', async () => { + const ignoredDir = path.join(tempDir, 'dist'); + fs.mkdirSync(ignoredDir); + const ignoredFile = path.join(ignoredDir, 'bundle.js'); + const watchedFile = path.join(tempDir, 'src.ts'); + fs.writeFileSync(ignoredFile, 'initial-dist'); + fs.writeFileSync(watchedFile, 'initial-src'); + + const watcher = await createWatcher({ + polling: true, + interval: 50, + cwd: tempDir, + ignored: [`${toPosixPathNormalized(ignoredDir)}/**`], + }); + + watcher.add(tempDir); + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + // Trigger changes in ignored file and watched file + fs.writeFileSync(ignoredFile, 'updated-dist'); + fs.writeFileSync(watchedFile, 'updated-src'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + const emitted = result.value?.all ?? []; + expect(emitted.some((f: string) => f.includes('src.ts'))).toBeTrue(); + expect(emitted.some((f: string) => f.includes('bundle.js'))).toBeFalse(); + + await watcher.close(); + }, 10000); + + it('should detect changes behind a directory symlink when followSymlinks is true', async () => { + const externalDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'watcher-external-')), + ); + let watcher: BuildWatcher | undefined; + + try { + const externalTargetFile = path.join(externalDir, 'index.ts'); + fs.writeFileSync(externalTargetFile, 'export const a = 1;'); + + const symlinkDir = path.join(tempDir, 'symlinked-lib'); + fs.symlinkSync(externalDir, symlinkDir, 'junction'); + + const symlinkedFile = path.join(symlinkDir, 'index.ts'); + + watcher = await createWatcher({ + followSymlinks: true, + cwd: tempDir, + }); + + watcher.add(symlinkedFile); + await setTimeout(150); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(externalTargetFile, 'export const a = 2;'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('index.ts'))).toBeTrue(); + } finally { + await watcher?.close(); + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + }); +}); diff --git a/packages/angular/cli/BUILD.bazel b/packages/angular/cli/BUILD.bazel index cf913b933bf4..2fbad284fd31 100644 --- a/packages/angular/cli/BUILD.bazel +++ b/packages/angular/cli/BUILD.bazel @@ -3,6 +3,8 @@ # Use of this source code is governed by an MIT-style license that can be # found in the LICENSE file at https://angular.dev/license +load("@aspect_rules_esbuild//esbuild:defs.bzl", "esbuild") +load("@aspect_rules_ts//ts:defs.bzl", "ts_config") load("@npm//:defs.bzl", "npm_link_all_packages") load("//tools:defaults.bzl", "jasmine_test", "npm_package", "ts_project") load("//tools:ng_cli_schema_generator.bzl", "cli_json_schema") @@ -14,6 +16,24 @@ package(default_visibility = ["//visibility:public"]) npm_link_all_packages() +ts_config( + name = "tsconfig-build", + src = "tsconfig-build.json", + deps = [ + "//:build-tsconfig", + ], +) + +ts_config( + name = "tsconfig-test", + src = "tsconfig-test.json", + deps = [ + ":tsconfig-build", + "//:node_modules/@types/jasmine", + "//:node_modules/@types/node", + ], +) + genrule( name = "angular_best_practices", srcs = [ @@ -25,19 +45,19 @@ genrule( """, ) -RUNTIME_ASSETS = glob( +PACKAGE_ASSETS = glob( include = [ "bin/**/*", - "src/**/*.md", ], exclude = [ "lib/config/workspace-schema.json", ], ) + [ "//packages/angular/cli:lib/config/schema.json", - ":angular_best_practices", ] +RUNTIME_ASSETS = PACKAGE_ASSETS + glob(["src/**/*.md"]) + [":angular_best_practices"] + ts_project( name = "angular-cli", srcs = glob( @@ -54,13 +74,14 @@ ts_project( "//packages/angular/cli:lib/config/workspace-schema.ts", ], data = RUNTIME_ASSETS, + tsconfig = ":tsconfig-build", deps = [ ":node_modules/@angular-devkit/architect", ":node_modules/@angular-devkit/core", ":node_modules/@angular-devkit/schematics", ":node_modules/@inquirer/prompts", ":node_modules/@listr2/prompt-adapter-inquirer", - ":node_modules/@modelcontextprotocol/sdk", + ":node_modules/@modelcontextprotocol/server", ":node_modules/jsonc-parser", ":node_modules/listr2", ":node_modules/npm-package-arg", @@ -77,6 +98,30 @@ ts_project( ], ) +esbuild( + name = "bundled_cli", + srcs = [ + ":angular-cli", + ":angular_best_practices", + ] + glob(["src/**/*.md"]), + config = { + "packages": "external", + "loader": { + ".md": "text", + }, + }, + entry_points = [ + "lib/cli/index.js", + "lib/init.js", + ], + format = "esm", + output_dir = True, + platform = "node", + sourcemap = False, + splitting = True, + target = "node22", +) + CLI_SCHEMA_DATA = [ "//packages/angular/build:schemas", "//packages/angular_devkit/build_angular:schemas", @@ -109,11 +154,12 @@ ts_project( "node_modules/**", ], ), + tsconfig = ":tsconfig-test", deps = [ ":angular-cli", ":node_modules/@angular-devkit/core", ":node_modules/@angular-devkit/schematics", - ":node_modules/@modelcontextprotocol/sdk", + ":node_modules/@modelcontextprotocol/server", ":node_modules/yargs", "//:node_modules/@types/semver", "//:node_modules/@types/yargs", @@ -124,7 +170,14 @@ ts_project( jasmine_test( name = "test", - data = [":angular-cli_test_lib"], + data = [ + "package.json", + "test-esm-loader.mjs", + ":angular-cli_test_lib", + ], + node_options = [ + "--import=./test-esm-loader.mjs", + ], ) genrule( @@ -144,14 +197,17 @@ npm_package( "//packages/angular_devkit/schematics:package.json", "//packages/schematics/angular:package.json", ], + replace_prefixes = { + "bundled_cli/": "lib/", + }, stamp_files = [ - "src/utilities/version.js", - "src/utilities/node-version.js", + "bin/version.js", ], tags = ["release-package"], - deps = RUNTIME_ASSETS + [ + deps = PACKAGE_ASSETS + [ ":README.md", - ":angular-cli", + ":bundled_cli", + ":index.d.ts", ":license", ], ) diff --git a/packages/angular/cli/bin/ng.js b/packages/angular/cli/bin/ng.js index ac01a0935fc0..d503fb895f5f 100755 --- a/packages/angular/cli/bin/ng.js +++ b/packages/angular/cli/bin/ng.js @@ -12,7 +12,7 @@ 'use strict'; const path = require('path'); -const nodeUtils = require('../src/utilities/node-version'); +const nodeUtils = require('./version'); // Error if the external CLI appears to be used inside a google3 context. if (process.cwd().split(path.sep).includes('google3')) { @@ -43,20 +43,19 @@ if (rawCommandName === '--get-yargs-completions' || rawCommandName === 'completi // This node version check ensures that extremely old versions of node are not used. // These may not support ES2015 features such as const/let/async/await/etc. // These would then crash with a hard to diagnose error message. -const [major] = process.versions.node.split('.', 1).map((part) => Number(part)); - -if (major === 23 || major === 25) { - // Allow new odd numbered releases with a warning. +if (nodeUtils.isNodeVersionSupported()) { + require('./bootstrap'); +} else if (nodeUtils.isNodeVersionRunnable()) { console.warn( 'Node.js version ' + process.version + ' detected.\n' + - 'Prior to version 27, odd numbered Node.js versions will not enter LTS status and should not be used for production.' + - ' For more information, please see https://nodejs.org/en/about/previous-releases/.', + 'The Angular CLI has not been officially tested on this Node.js version and it is not supported for production use.\n' + + 'For more information, please see https://nodejs.org/en/about/previous-releases/.', ); require('./bootstrap'); -} else if (!nodeUtils.isNodeVersionSupported()) { +} else { // Error and exit if less than the supported versions. console.error( 'Node.js version ' + @@ -69,6 +68,4 @@ if (major === 23 || major === 25) { ); process.exitCode = 3; -} else { - require('./bootstrap'); } diff --git a/packages/angular/cli/bin/version.js b/packages/angular/cli/bin/version.js new file mode 100644 index 000000000000..1755c1ed55c8 --- /dev/null +++ b/packages/angular/cli/bin/version.js @@ -0,0 +1,98 @@ +'use strict'; +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * The supported Node.js version for the Angular CLI. + */ +var SUPPORTED_NODE_VERSIONS = '0.0.0-ENGINES-NODE'; + +/** + * The version of the Angular CLI. + */ +var VERSION = '0.0.0-PLACEHOLDER'; + +/** + * The supported Node.js versions. + */ +var supportedNodeVersions = SUPPORTED_NODE_VERSIONS.replace(/[\^~<>=]/g, '') + .split('||') + .map(function (v) { + return v.trim(); + }); + +/** + * Checks if the current Node.js version is officially supported. + * @param {string} [currentVersion] Optional Node.js version string to check. Defaults to `process.versions.node`. + * @param {string[]} [supportedVersions] Optional supported versions array. Defaults to `supportedNodeVersions`. + * @returns {boolean} `true` if the current Node.js version is officially supported, `false` otherwise. + */ +function isNodeVersionSupported(currentVersion, supportedVersions) { + if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0' && !supportedVersions && !currentVersion) { + return true; + } + + var parts = (currentVersion || process.versions.node).split('.', 3).map(Number); + var processMajor = parts[0]; + var processMinor = parts[1]; + var processPatch = parts[2]; + + var versions = supportedVersions || supportedNodeVersions; + for (var i = 0; i < versions.length; i++) { + var vParts = versions[i].split('.', 3).map(Number); + var major = vParts[0]; + var minor = vParts[1]; + var patch = vParts[2]; + if ( + (major === processMajor && processMinor === minor && processPatch >= patch) || + (major === processMajor && processMinor > minor) + ) { + return true; + } + } + + return false; +} + +/** + * Checks if the current Node.js version is runnable (newer than supported LTS or an interim odd release). + * @param {string} [currentVersion] Optional Node.js version string to check. Defaults to `process.versions.node`. + * @param {string[]} [supportedVersions] Optional supported versions array. Defaults to `supportedNodeVersions`. + * @returns {boolean} `true` if the current Node.js version is runnable, `false` otherwise. + */ +function isNodeVersionRunnable(currentVersion, supportedVersions) { + if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0' && !supportedVersions && !currentVersion) { + return false; + } + + var processMajor = Number((currentVersion || process.versions.node).split('.', 1)[0]); + var versions = supportedVersions || supportedNodeVersions; + + var minMajor = Number(versions[0].split('.', 1)[0]); + var maxMajor = Number(versions[versions.length - 1].split('.', 1)[0]); + + // Versions newer than the maximum supported major (e.g. Node 27, 28+) + if (processMajor > maxMajor) { + return true; + } + + // Odd-numbered interim releases between min and max (e.g. Node 23, 25) + if (processMajor > minMajor && processMajor % 2 === 1) { + return true; + } + + return false; +} + +module.exports = { + VERSION: VERSION, + SUPPORTED_NODE_VERSIONS: SUPPORTED_NODE_VERSIONS, + supportedNodeVersions: supportedNodeVersions, + isNodeVersionSupported: isNodeVersionSupported, + isNodeVersionRunnable: isNodeVersionRunnable, +}; diff --git a/packages/angular/cli/index.d.ts b/packages/angular/cli/index.d.ts new file mode 100644 index 000000000000..2a73c4bbc059 --- /dev/null +++ b/packages/angular/cli/index.d.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export declare class Version { + readonly full: string; + readonly major: string; + readonly minor: string; + readonly patch: string; + constructor(full: string); +} + +export declare const VERSION: Version; + +export default function (options: { cliArgs: string[] }): Promise; diff --git a/packages/angular/cli/lib/init.ts b/packages/angular/cli/lib/init.ts index cd324b6df69b..a503d9a87f70 100644 --- a/packages/angular/cli/lib/init.ts +++ b/packages/angular/cli/lib/init.ts @@ -9,9 +9,8 @@ import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; import { SemVer, major } from 'semver'; -import { colors } from '../src/utilities/color'; -import { isWarningEnabled } from '../src/utilities/config'; import { disableVersionCheck } from '../src/utilities/environment-options'; import { VERSION } from '../src/utilities/version'; @@ -24,6 +23,26 @@ import { VERSION } from '../src/utilities/version'; let forceExit = false; (async (): Promise => { + /** + * On Windows, ensure process.cwd() uses uppercase drive letter casing. + * Windows cmd.exe and VS Code terminals often preserve lowercase drive letters (e.g. c:\...), + * which causes ESM module duplication in Node.js (e.g. loading "vitest" twice) and path + * lookup mismatches in tools like Vite and esbuild. + */ + if (process.platform === 'win32') { + const cwd = process.cwd(); + if (/^[a-z]:/.test(cwd)) { + try { + process.chdir(cwd[0].toUpperCase() + cwd.slice(1)); + } catch { + // Ignore failure to change directory + } + } + + // Ensure Windows CreateProcess does not search the current directory for bare executable names. + process.env['NoDefaultCurrentDirectoryInExePath'] = '1'; + } + /** * Disable Browserslist old data warning as otherwise with every release we'd need to update this dependency * which is cumbersome considering we pin versions and the warning is not user actionable. @@ -31,6 +50,7 @@ let forceExit = false; * See: https://github.com/browserslist/browserslist/blob/819c4337456996d19db6ba953014579329e9c6e1/node.js#L324 */ process.env.BROWSERSLIST_IGNORE_OLD_DATA = '1'; + const rawCommandName = process.argv[2]; /** @@ -52,7 +72,7 @@ let forceExit = false; // version of ng-cli you have installed in a local package.json const cwdRequire = createRequire(process.cwd() + '/'); const projectLocalCli = cwdRequire.resolve('@angular/cli'); - cli = await import(projectLocalCli); + cli = await import(pathToFileURL(projectLocalCli).href); const globalVersion = new SemVer(VERSION.full); @@ -104,15 +124,23 @@ let forceExit = false; cli.VERSION.major - globalVersion.major <= 1 ) { cli = await import('./cli'); - } else if (await isWarningEnabled('versionMismatch')) { - // Otherwise, use local version and warn if global is newer than local - const warning = - `Your global Angular CLI version (${globalVersion}) is greater than your local ` + - `version (${localVersion}). The local Angular CLI version is used.\n\n` + - 'To disable this warning use "ng config -g cli.warnings.versionMismatch false".'; + } else { + try { + const { isWarningEnabled } = await import('../src/utilities/config'); + if (await isWarningEnabled('versionMismatch')) { + // Otherwise, use local version and warn if global is newer than local + const warning = + `Your global Angular CLI version (${globalVersion}) is greater than your local ` + + `version (${localVersion}). The local Angular CLI version is used.\n\n` + + 'To disable this warning use "ng config -g cli.warnings.versionMismatch false".'; - // eslint-disable-next-line no-console - console.error(colors.yellow(warning)); + const { colors } = await import('../src/utilities/color'); + // eslint-disable-next-line no-console + console.error(colors.yellow(warning)); + } + } catch { + // Ignore errors during warning check to avoid falling back to global CLI + } } } } catch { @@ -123,7 +151,11 @@ let forceExit = false; cli = await import('./cli'); } - if ('default' in cli) { + // Support both ESM and CommonJS local CLI packages. When importing older CommonJS + // packages with an `__esModule` default export, Node.js wraps the exports in an ESM + // namespace requiring the default export to be unwrapped multiple times. + let depth = 0; + while (typeof cli === 'object' && cli !== null && 'default' in cli && depth++ < 3) { cli = cli['default']; } diff --git a/packages/angular/cli/package.json b/packages/angular/cli/package.json index 1d458f516d06..bae0b97abe97 100644 --- a/packages/angular/cli/package.json +++ b/packages/angular/cli/package.json @@ -3,6 +3,8 @@ "version": "0.0.0-PLACEHOLDER", "description": "CLI tool for Angular", "main": "lib/cli/index.js", + "typings": "index.d.ts", + "type": "module", "bin": { "ng": "bin/ng.js" }, @@ -11,21 +13,24 @@ "angular-cli", "Angular CLI" ], + "imports": { + "#version": "./bin/version.js" + }, "dependencies": { "@angular-devkit/architect": "workspace:0.0.0-EXPERIMENTAL-PLACEHOLDER", "@angular-devkit/core": "workspace:0.0.0-PLACEHOLDER", "@angular-devkit/schematics": "workspace:0.0.0-PLACEHOLDER", - "@inquirer/prompts": "8.5.2", - "@listr2/prompt-adapter-inquirer": "4.2.4", - "@modelcontextprotocol/sdk": "1.29.0", + "@inquirer/prompts": "8.7.2", + "@listr2/prompt-adapter-inquirer": "4.2.7", + "@modelcontextprotocol/server": "2.0.0", "@schematics/angular": "workspace:0.0.0-PLACEHOLDER", "jsonc-parser": "3.3.1", - "listr2": "10.2.2", + "listr2": "11.1.0", "npm-package-arg": "14.0.0", "parse5-html-rewriting-stream": "8.0.1", "semver": "7.8.5", - "yargs": "18.0.0", - "zod": "4.4.3" + "yargs": "18.1.0", + "zod": "4.6.5" }, "ng-update": { "migrations": "@schematics/angular/migrations/migration-collection.json", diff --git a/packages/angular/cli/src/command-builder/command-module.ts b/packages/angular/cli/src/command-builder/command-module.ts index ae0eb82f0cb5..ec92bc452d5b 100644 --- a/packages/angular/cli/src/command-builder/command-module.ts +++ b/packages/angular/cli/src/command-builder/command-module.ts @@ -7,8 +7,6 @@ */ import { schema } from '@angular-devkit/core'; -import { readFileSync } from 'node:fs'; -import { join, posix, relative } from 'node:path'; import type { ArgumentsCamelCase, Argv, CommandModule as YargsCommandModule } from 'yargs'; import { Parser as yargsParser } from 'yargs/helpers'; import { getAnalyticsUserId } from '../analytics/analytics'; @@ -31,8 +29,11 @@ export interface CommandModuleImplementation extends Omit< /** Scope in which the command can be executed in. */ scope: CommandScope; - /** Path used to load the long description for the command in JSON help text. */ - longDescriptionPath?: string; + /** Long description for the command in JSON help text. */ + longDescription?: string; + + /** Relative path to the long description file for the command in JSON help text. */ + longDescriptionRelativePath?: string; /** Object declaring the options the command accepts, or a function accepting and returning a yargs instance. */ builder(argv: Argv): Promise> | Argv; @@ -50,7 +51,8 @@ export interface FullDescribe { export abstract class CommandModule implements CommandModuleImplementation { abstract readonly command: string; abstract readonly describe: string | false; - abstract readonly longDescriptionPath?: string; + readonly longDescription?: string; + readonly longDescriptionRelativePath?: string; protected readonly shouldReportAnalytics: boolean = true; readonly scope: CommandScope = CommandScope.Both; @@ -68,23 +70,22 @@ export abstract class CommandModule implements CommandModuleI * `false` will result in a hidden command. */ public get fullDescribe(): FullDescribe | false { - return this.describe === false - ? false - : { - describe: this.describe, - ...(this.longDescriptionPath - ? { - longDescriptionRelativePath: relative( - join(__dirname, '../../../../'), - this.longDescriptionPath, - ).replace(/\\/g, posix.sep), - longDescription: readFileSync(this.longDescriptionPath, 'utf8').replace( - /\r\n/g, - '\n', - ), - } - : {}), - }; + if (this.describe === false) { + return false; + } + + const description: FullDescribe = { + describe: this.describe, + }; + + if (this.longDescription) { + description.longDescription = this.longDescription.replace(/\r\n/g, '\n'); + description.longDescriptionRelativePath = + this.longDescriptionRelativePath ?? + `@angular/cli/src/commands/${this.commandName}/long-description.md`; + } + + return description; } protected get commandName(): string { diff --git a/packages/angular/cli/src/command-builder/command-runner.ts b/packages/angular/cli/src/command-builder/command-runner.ts index 452f9afe8f68..62de365dee99 100644 --- a/packages/angular/cli/src/command-builder/command-runner.ts +++ b/packages/angular/cli/src/command-builder/command-runner.ts @@ -102,10 +102,16 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis addCommandModuleToYargs(CommandModule, context); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const usageInstance = (localYargs as any).getInternalMethods().getUsageInstance(); if (jsonHelp) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const usageInstance = (localYargs as any).getInternalMethods().getUsageInstance(); usageInstance.help = () => jsonHelpUsage(localYargs); + } else if (!help) { + // Yargs eagerly caches and formats the full command help (including table wrapping + // and Unicode string-width calculations via cliui) whenever a subcommand executes, + // in case the command fails. Because showHelpOnFail is disabled, this formatted text + // is never used. Skipping this work avoids non-trivial synchronous overhead during startup. + usageInstance.cacheHelpMessage = () => {}; } // Add default command to support version option when no subcommand is specified diff --git a/packages/angular/cli/src/command-builder/definitions.ts b/packages/angular/cli/src/command-builder/definitions.ts index d552b432b685..27c6dc8a6ae9 100644 --- a/packages/angular/cli/src/command-builder/definitions.ts +++ b/packages/angular/cli/src/command-builder/definitions.ts @@ -7,7 +7,7 @@ */ import { logging } from '@angular-devkit/core'; -import type { Argv, CamelCaseKey } from 'yargs'; +import type { Argv, CamelCaseKey } from 'yargs' with { 'resolution-mode': 'require' }; import type { PackageManager } from '../package-managers/package-manager'; import { AngularWorkspace } from '../utilities/config'; diff --git a/packages/angular/cli/src/command-builder/schematics-command-module.ts b/packages/angular/cli/src/command-builder/schematics-command-module.ts index c74b44101e93..e2561fd1c273 100644 --- a/packages/angular/cli/src/command-builder/schematics-command-module.ts +++ b/packages/angular/cli/src/command-builder/schematics-command-module.ts @@ -420,10 +420,10 @@ export abstract class SchematicsCommandModule return workspace ? // Workspace collectionName === DEFAULT_SCHEMATICS_COLLECTION - ? // Favor __dirname for @schematics/angular to use the build-in version - [__dirname, process.cwd(), root] - : [process.cwd(), root, __dirname] + ? // Favor import.meta.dirname for @schematics/angular to use the build-in version + [import.meta.dirname, process.cwd(), root] + : [process.cwd(), root, import.meta.dirname] : // Global - [__dirname, process.cwd()]; + [import.meta.dirname, process.cwd()]; } } diff --git a/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts b/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts index 11228e4adca0..496a2094b62c 100644 --- a/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts +++ b/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts @@ -7,6 +7,7 @@ */ import { JsonObject, schema } from '@angular-devkit/core'; +import type { Argv } from 'yargs'; import yargs from 'yargs'; import { Option, addSchemaOptionsToCommand, parseJsonSchemaToOptions } from './json-schema'; @@ -20,7 +21,7 @@ describe('parseJsonSchemaToOptions', () => { return localYargs.parseAsync(args); }; - let localYargs: yargs.Argv; + let localYargs: Argv; let options: Option[]; beforeAll(async () => { diff --git a/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts b/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts index 25b723c467a2..067d4cd00460 100644 --- a/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts +++ b/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts @@ -73,7 +73,7 @@ export class SchematicEngineHost extends NodeModulesEngineHost { // Mimic behavior of ExportStringRef class used in default behavior const fullPath = path[0] === '.' ? resolve(parentPath ?? process.cwd(), path) : path; - const referenceRequire = createRequire(__filename); + const referenceRequire = createRequire(import.meta.url); const schematicFile = referenceRequire.resolve(fullPath, { paths: [parentPath] }); if (shouldWrapSchematic(schematicFile, collectionDescription?.encapsulation)) { @@ -139,7 +139,7 @@ function wrap( moduleCache: Map, exportName?: string, ): () => unknown { - const hostRequire = createRequire(__filename); + const hostRequire = createRequire(import.meta.url); const schematicRequire = createRequire(schematicFile); const customRequire = function (id: string) { diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts index a3a3d6f7ecd2..d7efd10906d9 100644 --- a/packages/angular/cli/src/commands/add/cli.ts +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -29,6 +29,7 @@ import { NgAddSaveDependency, PackageManifest, PackageMetadata } from '../../pac import { assertIsError } from '../../utilities/error'; import { isTTY } from '../../utilities/tty'; import { VERSION } from '../../utilities/version'; +import longDescription from './long-description.md'; class CommandError extends Error {} @@ -100,7 +101,7 @@ export default class AddCommandModule { command = 'add '; describe = 'Adds support for an external library to your project.'; - longDescriptionPath = join(__dirname, 'long-description.md'); + override longDescription = longDescription; protected override allowPrivateSchematics = true; private readonly schematicName = 'ng-add'; private rootRequire = createRequire(this.context.root + '/'); @@ -243,9 +244,7 @@ export default class AddCommandModule }, // TODO: Rework schematic execution as a task and insert here ], - { - /* options */ - }, + {/* options */}, ); try { @@ -263,6 +262,9 @@ export default class AddCommandModule if (localManifest['ng-add']?.save === false) { shouldCleanUp = true; } + } else { + await this.cleanUpTemporaryDependency(result.collectionName); + shouldCleanUp = false; } } catch {} } diff --git a/packages/angular/cli/src/commands/analytics/cli.ts b/packages/angular/cli/src/commands/analytics/cli.ts index da56a2a00460..bcd67b0bba45 100644 --- a/packages/angular/cli/src/commands/analytics/cli.ts +++ b/packages/angular/cli/src/commands/analytics/cli.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import { join } from 'node:path'; import { Argv } from 'yargs'; import { CommandModule, @@ -18,6 +17,7 @@ import { demandCommandFailureMessage, } from '../../command-builder/utilities/command'; import { AnalyticsInfoCommandModule } from './info/cli'; +import longDescription from './long-description.md'; import { AnalyticsDisableModule, AnalyticsEnableModule, @@ -30,7 +30,7 @@ export default class AnalyticsCommandModule { command = 'analytics'; describe = 'Configures the gathering of Angular CLI usage metrics.'; - longDescriptionPath = join(__dirname, 'long-description.md'); + override longDescription = longDescription; builder(localYargs: Argv): Argv { const subcommands = [ diff --git a/packages/angular/cli/src/commands/analytics/info/cli.ts b/packages/angular/cli/src/commands/analytics/info/cli.ts index e4434d35baee..93b34fd06716 100644 --- a/packages/angular/cli/src/commands/analytics/info/cli.ts +++ b/packages/angular/cli/src/commands/analytics/info/cli.ts @@ -20,7 +20,6 @@ export class AnalyticsInfoCommandModule { command = 'info'; describe = 'Prints analytics gathering and reporting configuration in the console.'; - longDescriptionPath?: string; builder(localYargs: Argv): Argv { return localYargs.strict(); diff --git a/packages/angular/cli/src/commands/analytics/settings/cli.ts b/packages/angular/cli/src/commands/analytics/settings/cli.ts index 16f07b353d1a..24105de2b060 100644 --- a/packages/angular/cli/src/commands/analytics/settings/cli.ts +++ b/packages/angular/cli/src/commands/analytics/settings/cli.ts @@ -26,8 +26,6 @@ abstract class AnalyticsSettingModule extends CommandModule implements CommandModuleImplementation { - longDescriptionPath?: string; - builder(localYargs: Argv): Argv { return localYargs .option('global', { diff --git a/packages/angular/cli/src/commands/build/cli.ts b/packages/angular/cli/src/commands/build/cli.ts index 365420ca3734..c722f70709e3 100644 --- a/packages/angular/cli/src/commands/build/cli.ts +++ b/packages/angular/cli/src/commands/build/cli.ts @@ -6,10 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ -import { join } from 'node:path'; import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; import { CommandModuleImplementation } from '../../command-builder/command-module'; import { RootCommands } from '../command-config'; +import longDescription from './long-description.md'; export default class BuildCommandModule extends ArchitectCommandModule @@ -20,5 +20,5 @@ export default class BuildCommandModule aliases = RootCommands['build'].aliases; describe = 'Compiles an Angular application or library into an output directory named dist/ at the given output path.'; - longDescriptionPath = join(__dirname, 'long-description.md'); + override longDescription = longDescription; } diff --git a/packages/angular/cli/src/commands/cache/clean/cli.ts b/packages/angular/cli/src/commands/cache/clean/cli.ts index a115b686b7e0..492699271b54 100644 --- a/packages/angular/cli/src/commands/cache/clean/cli.ts +++ b/packages/angular/cli/src/commands/cache/clean/cli.ts @@ -18,7 +18,6 @@ import { getCacheConfig } from '../utilities'; export class CacheCleanModule extends CommandModule implements CommandModuleImplementation { command = 'clean'; describe = 'Deletes persistent disk cache from disk.'; - longDescriptionPath: string | undefined; override scope = CommandScope.In; builder(localYargs: Argv): Argv { diff --git a/packages/angular/cli/src/commands/cache/cli.ts b/packages/angular/cli/src/commands/cache/cli.ts index dad144b034b3..4ce2b7ebd1e4 100644 --- a/packages/angular/cli/src/commands/cache/cli.ts +++ b/packages/angular/cli/src/commands/cache/cli.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import { join } from 'node:path'; import { Argv } from 'yargs'; import { CommandModule, @@ -20,6 +19,7 @@ import { } from '../../command-builder/utilities/command'; import { CacheCleanModule } from './clean/cli'; import { CacheInfoCommandModule } from './info/cli'; +import longDescription from './long-description.md'; import { CacheDisableModule, CacheEnableModule } from './settings/cli'; export default class CacheCommandModule @@ -28,7 +28,7 @@ export default class CacheCommandModule { command = 'cache'; describe = 'Configure persistent disk cache and retrieve cache statistics.'; - longDescriptionPath = join(__dirname, 'long-description.md'); + override longDescription = longDescription; override scope = CommandScope.In; builder(localYargs: Argv): Argv { diff --git a/packages/angular/cli/src/commands/cache/info/cli.ts b/packages/angular/cli/src/commands/cache/info/cli.ts index f4278d52db74..6fd2df0414c7 100644 --- a/packages/angular/cli/src/commands/cache/info/cli.ts +++ b/packages/angular/cli/src/commands/cache/info/cli.ts @@ -21,7 +21,6 @@ import { getCacheConfig } from '../utilities'; export class CacheInfoCommandModule extends CommandModule implements CommandModuleImplementation { command = 'info'; describe = 'Prints persistent disk cache configuration and statistics in the console.'; - longDescriptionPath?: string | undefined; override scope = CommandScope.In; builder(localYargs: Argv): Argv { diff --git a/packages/angular/cli/src/commands/cache/settings/cli.ts b/packages/angular/cli/src/commands/cache/settings/cli.ts index 9a4f654f7ac7..5de2d5496475 100644 --- a/packages/angular/cli/src/commands/cache/settings/cli.ts +++ b/packages/angular/cli/src/commands/cache/settings/cli.ts @@ -18,7 +18,6 @@ export class CacheDisableModule extends CommandModule implements CommandModuleIm command = 'disable'; aliases = 'off'; describe = 'Disables persistent disk cache for all projects in the workspace.'; - longDescriptionPath: string | undefined; override scope = CommandScope.In; builder(localYargs: Argv): Argv { @@ -34,7 +33,6 @@ export class CacheEnableModule extends CommandModule implements CommandModuleImp command = 'enable'; aliases = 'on'; describe = 'Enables disk cache for all projects in the workspace.'; - longDescriptionPath: string | undefined; override scope = CommandScope.In; builder(localYargs: Argv): Argv { diff --git a/packages/angular/cli/src/commands/completion/cli.ts b/packages/angular/cli/src/commands/completion/cli.ts index 3fc9dccdc703..5b7bc066e577 100644 --- a/packages/angular/cli/src/commands/completion/cli.ts +++ b/packages/angular/cli/src/commands/completion/cli.ts @@ -6,13 +6,13 @@ * found in the LICENSE file at https://angular.dev/license */ -import { join } from 'node:path'; import { Argv } from 'yargs'; import { CommandModule, CommandModuleImplementation } from '../../command-builder/command-module'; import { addCommandModuleToYargs } from '../../command-builder/utilities/command'; import { colors } from '../../utilities/color'; import { hasGlobalCliInstall, initializeAutocomplete } from '../../utilities/completion'; import { assertIsError } from '../../utilities/error'; +import longDescription from './long-description.md'; export default class CompletionCommandModule extends CommandModule @@ -20,7 +20,7 @@ export default class CompletionCommandModule { command = 'completion'; describe = 'Set up Angular CLI autocompletion for your terminal.'; - longDescriptionPath = join(__dirname, 'long-description.md'); + override longDescription = longDescription; builder(localYargs: Argv): Argv { addCommandModuleToYargs(CompletionScriptCommandModule, this.context); @@ -64,7 +64,6 @@ Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your termi class CompletionScriptCommandModule extends CommandModule implements CommandModuleImplementation { command = 'script'; describe = 'Generate a bash and zsh real-time type-ahead autocompletion script.'; - longDescriptionPath = undefined; builder(localYargs: Argv): Argv { return localYargs; diff --git a/packages/angular/cli/src/commands/config/cli.ts b/packages/angular/cli/src/commands/config/cli.ts index 06b253b9a42d..ae25f3839e22 100644 --- a/packages/angular/cli/src/commands/config/cli.ts +++ b/packages/angular/cli/src/commands/config/cli.ts @@ -8,7 +8,6 @@ import { JsonValue } from '@angular-devkit/core'; import { randomUUID } from 'node:crypto'; -import { join } from 'node:path'; import { Argv } from 'yargs'; import { CommandModule, @@ -18,6 +17,7 @@ import { } from '../../command-builder/command-module'; import { getWorkspaceRaw, validateWorkspace } from '../../utilities/config'; import { JSONFile, parseJson } from '../../utilities/json-file'; +import longDescription from './long-description.md'; interface ConfigCommandArgs { 'json-path'?: string; @@ -32,7 +32,7 @@ export default class ConfigCommandModule command = 'config [json-path] [value]'; describe = 'Retrieves or sets Angular configuration values in the angular.json file for the workspace.'; - longDescriptionPath = join(__dirname, 'long-description.md'); + override longDescription = longDescription; builder(localYargs: Argv): Argv { return localYargs diff --git a/packages/angular/cli/src/commands/deploy/cli.ts b/packages/angular/cli/src/commands/deploy/cli.ts index 947dc90af2d4..fb305c94aaf1 100644 --- a/packages/angular/cli/src/commands/deploy/cli.ts +++ b/packages/angular/cli/src/commands/deploy/cli.ts @@ -6,10 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ -import { join } from 'node:path'; import { MissingTargetChoice } from '../../command-builder/architect-base-command-module'; import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; import { CommandModuleImplementation } from '../../command-builder/command-module'; +import longDescription from './long-description.md'; export default class DeployCommandModule extends ArchitectCommandModule @@ -37,7 +37,7 @@ export default class DeployCommandModule multiTarget = false; command = 'deploy [project]'; - longDescriptionPath = join(__dirname, 'long-description.md'); + override longDescription = longDescription; describe = 'Invokes the deploy builder for a specified project or for the default project in the workspace.'; } diff --git a/packages/angular/cli/src/commands/e2e/cli.ts b/packages/angular/cli/src/commands/e2e/cli.ts index 85d9aab173a0..9d63a8d96fb4 100644 --- a/packages/angular/cli/src/commands/e2e/cli.ts +++ b/packages/angular/cli/src/commands/e2e/cli.ts @@ -42,5 +42,4 @@ export default class E2eCommandModule command = 'e2e [project]'; aliases = RootCommands['e2e'].aliases; describe = 'Builds and serves an Angular application, then runs end-to-end tests.'; - longDescriptionPath?: string; } diff --git a/packages/angular/cli/src/commands/extract-i18n/cli.ts b/packages/angular/cli/src/commands/extract-i18n/cli.ts index 4f3dea2d8e7e..09b3df343403 100644 --- a/packages/angular/cli/src/commands/extract-i18n/cli.ts +++ b/packages/angular/cli/src/commands/extract-i18n/cli.ts @@ -19,7 +19,6 @@ export default class ExtractI18nCommandModule multiTarget = false; command = 'extract-i18n [project]'; describe = 'Extracts i18n messages from source code.'; - longDescriptionPath?: string | undefined; override async findDefaultBuilderName( project: workspaces.ProjectDefinition, diff --git a/packages/angular/cli/src/commands/generate/cli.ts b/packages/angular/cli/src/commands/generate/cli.ts index 4be29c3eaea0..8f605e03a18b 100644 --- a/packages/angular/cli/src/commands/generate/cli.ts +++ b/packages/angular/cli/src/commands/generate/cli.ts @@ -38,7 +38,6 @@ export default class GenerateCommandModule command = 'generate'; aliases = RootCommands['generate'].aliases; describe = 'Generates and/or modifies files based on a schematic.'; - longDescriptionPath?: string | undefined; override async builder(argv: Argv): Promise> { let localYargs = (await super.builder(argv)).command({ diff --git a/packages/angular/cli/src/commands/lint/cli.ts b/packages/angular/cli/src/commands/lint/cli.ts index 9510dd7afe53..dd7e7c6f193b 100644 --- a/packages/angular/cli/src/commands/lint/cli.ts +++ b/packages/angular/cli/src/commands/lint/cli.ts @@ -6,10 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ -import { join } from 'node:path'; import { MissingTargetChoice } from '../../command-builder/architect-base-command-module'; import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; import { CommandModuleImplementation } from '../../command-builder/command-module'; +import longDescription from './long-description.md'; export default class LintCommandModule extends ArchitectCommandModule @@ -24,6 +24,6 @@ export default class LintCommandModule multiTarget = true; command = 'lint [project]'; - longDescriptionPath = join(__dirname, 'long-description.md'); + override longDescription = longDescription; describe = 'Runs linting tools on Angular application code in a given project folder.'; } diff --git a/packages/angular/cli/src/commands/make-this-awesome/cli.ts b/packages/angular/cli/src/commands/make-this-awesome/cli.ts index 6a17c5614b94..8e36447f1f3a 100644 --- a/packages/angular/cli/src/commands/make-this-awesome/cli.ts +++ b/packages/angular/cli/src/commands/make-this-awesome/cli.ts @@ -17,7 +17,6 @@ export default class AwesomeCommandModule command = 'make-this-awesome'; describe = false as const; deprecated = false; - longDescriptionPath?: string | undefined; builder(localYargs: Argv): Argv { return localYargs; diff --git a/packages/angular/cli/src/commands/mcp/cli.ts b/packages/angular/cli/src/commands/mcp/cli.ts index f72c3ea57fd3..cbc94499a9ca 100644 --- a/packages/angular/cli/src/commands/mcp/cli.ts +++ b/packages/angular/cli/src/commands/mcp/cli.ts @@ -6,14 +6,14 @@ * found in the LICENSE file at https://angular.dev/license */ -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; import type { Argv } from 'yargs'; import { CommandModule, type CommandModuleImplementation, } from '../../command-builder/command-module'; import { isTTY } from '../../utilities/tty'; -import { EXPERIMENTAL_TOOLS, EXPERIMENTAL_TOOL_GROUPS, createMcpServer } from './mcp-server'; +import { createMcpServer } from './mcp-server'; const INTERACTIVE_MESSAGE = ` To start using the Angular CLI MCP Server, add this configuration to your host: @@ -35,10 +35,15 @@ For more information and documentation, visit: https://angular.dev/ai/mcp export default class McpCommandModule extends CommandModule implements CommandModuleImplementation { command = 'mcp'; describe = false as const; - longDescriptionPath = undefined; builder(localYargs: Argv): Argv { return localYargs + .option('root', { + type: 'string', + array: true, + describe: + 'Allowed root directory paths for filesystem access and workspace discovery. Can be specified multiple times.', + }) .option('read-only', { type: 'boolean', default: false, @@ -59,6 +64,7 @@ export default class McpCommandModule extends CommandModule implements CommandMo } async run(options: { + root: string[] | undefined; readOnly: boolean; localOnly: boolean; experimentalTool: string[] | undefined; @@ -69,16 +75,17 @@ export default class McpCommandModule extends CommandModule implements CommandMo return; } - const server = await createMcpServer( - { - workspace: this.context.workspace, - readOnly: options.readOnly, - localOnly: options.localOnly, - experimentalTools: options.experimentalTool, - }, - this.context.logger, + serveStdio(() => + createMcpServer( + { + workspace: this.context.workspace, + readOnly: options.readOnly, + localOnly: options.localOnly, + experimentalTools: options.experimentalTool, + roots: options.root, + }, + this.context.logger, + ), ); - const transport = new StdioServerTransport(); - await server.connect(transport); } } diff --git a/packages/angular/cli/src/commands/mcp/host.ts b/packages/angular/cli/src/commands/mcp/host.ts index 5dda0ade077f..0ec914727e41 100644 --- a/packages/angular/cli/src/commands/mcp/host.ts +++ b/packages/angular/cli/src/commands/mcp/host.ts @@ -282,11 +282,22 @@ export const LocalWorkspaceHost: Host = { }, }; +function resolveRoots(roots: string[]): string[] { + return roots.map((r) => { + try { + return realpathSync(resolve(r)); + } catch { + return resolve(r); + } + }); +} + export function createRootRestrictedHost( baseHost: Host, initialRoots: string[] = [process.cwd()], ): Host { - let roots = initialRoots; + const defaultRoots = resolveRoots(initialRoots); + let roots = defaultRoots; function checkPath(path: string) { const resolvedPath = resolve(path); @@ -332,7 +343,7 @@ export function createRootRestrictedHost( return { ...baseHost, setRoots(newRoots: string[]) { - roots = newRoots; + roots = newRoots.length > 0 ? resolveRoots(newRoots) : defaultRoots; }, stat(path: string) { checkPath(path); diff --git a/packages/angular/cli/src/commands/mcp/host_spec.ts b/packages/angular/cli/src/commands/mcp/host_spec.ts new file mode 100644 index 000000000000..2b2d3d9ea60a --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/host_spec.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { LocalWorkspaceHost, createRootRestrictedHost } from './host'; + +describe('createRootRestrictedHost', () => { + let root1: string; + let root2: string; + let outsideDir: string; + + beforeEach(() => { + root1 = mkdtempSync(join(tmpdir(), 'angular-cli-mcp-root1-')); + root2 = mkdtempSync(join(tmpdir(), 'angular-cli-mcp-root2-')); + outsideDir = mkdtempSync(join(tmpdir(), 'angular-cli-mcp-outside-')); + + writeFileSync(join(root1, 'file1.txt'), 'root 1 content'); + writeFileSync(join(root2, 'file2.txt'), 'root 2 content'); + writeFileSync(join(outsideDir, 'outside.txt'), 'outside content'); + }); + + afterEach(() => { + rmSync(root1, { recursive: true, force: true }); + rmSync(root2, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + }); + + it('should allow file access inside any of the configured initial roots', () => { + const host = createRootRestrictedHost(LocalWorkspaceHost, [root1, root2]); + + expect(host.existsSync(join(root1, 'file1.txt'))).toBeTrue(); + expect(host.existsSync(join(root2, 'file2.txt'))).toBeTrue(); + }); + + it('should reject file access outside of the configured roots', () => { + const host = createRootRestrictedHost(LocalWorkspaceHost, [root1, root2]); + + expect(() => host.existsSync(join(outsideDir, 'outside.txt'))).toThrowError( + new RegExp( + `Access denied: path '${join(outsideDir, 'outside.txt')}' is outside allowed roots.`, + ), + ); + }); + + it('should fall back to initial roots when setRoots is called with an empty array', () => { + const host = createRootRestrictedHost(LocalWorkspaceHost, [root1, root2]); + + host.setRoots([]); + + expect(host.existsSync(join(root1, 'file1.txt'))).toBeTrue(); + expect(host.existsSync(join(root2, 'file2.txt'))).toBeTrue(); + }); +}); diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts index 58328eed24f2..e50fc3dfd3ac 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -6,9 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { RootsListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; -import { join, normalize } from 'node:path'; +import { McpServer } from '@modelcontextprotocol/server'; +import { normalize, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { AngularWorkspace } from '../../utilities/config'; import { VERSION } from '../../utilities/version'; @@ -67,6 +66,7 @@ export async function createMcpServer( readOnly?: boolean; localOnly?: boolean; experimentalTools?: string[]; + roots?: string[]; }, logger: { warn(text: string): void }, ): Promise { @@ -122,7 +122,12 @@ for equivalent actions. logger, }); - const restrictedHost = createRootRestrictedHost(LocalWorkspaceHost); + const resolvedRoots = options.roots?.map((r) => resolve(r)); + + const restrictedHost = createRootRestrictedHost( + LocalWorkspaceHost, + resolvedRoots?.length ? resolvedRoots : [process.cwd()], + ); server.server.oninitialized = () => { void (async () => { @@ -134,7 +139,7 @@ for equivalent actions. restrictedHost.setRoots(searchRoots); if (clientCapabilities.roots.listChanged) { - server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => { + server.server.setNotificationHandler('notifications/roots/list_changed', async () => { try { const { roots: updatedRoots } = await server.server.listRoots(); const updatedSearchRoots = @@ -161,9 +166,9 @@ for equivalent actions. { workspace: options.workspace, logger, - exampleDatabasePath: join(__dirname, '../../../lib/code-examples.db'), devservers: new Map(), host: restrictedHost, + roots: resolvedRoots, }, toolDeclarations, ); diff --git a/packages/angular/cli/src/commands/mcp/resources/instructions.ts b/packages/angular/cli/src/commands/mcp/resources/instructions.ts index bf85a3d54384..636de5471a1c 100644 --- a/packages/angular/cli/src/commands/mcp/resources/instructions.ts +++ b/packages/angular/cli/src/commands/mcp/resources/instructions.ts @@ -6,9 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import type { McpServer } from '@modelcontextprotocol/server'; +import bestPracticesText from './best-practices.md'; export function registerInstructionsResource(server: McpServer): void { server.registerResource( @@ -23,10 +22,8 @@ export function registerInstructionsResource(server: McpServer): void { ' typed forms, modern control flow syntax, and other current conventions.', mimeType: 'text/markdown', }, - async () => { - const text = await readFile(join(__dirname, 'best-practices.md'), 'utf-8'); - - return { contents: [{ uri: 'instructions://best-practices', text }] }; - }, + async () => ({ + contents: [{ uri: 'instructions://best-practices', text: bestPracticesText }], + }), ); } diff --git a/packages/angular/cli/src/commands/mcp/testing/test-utils.ts b/packages/angular/cli/src/commands/mcp/testing/test-utils.ts index 1c95c51fe25e..97a11ac863be 100644 --- a/packages/angular/cli/src/commands/mcp/testing/test-utils.ts +++ b/packages/angular/cli/src/commands/mcp/testing/test-utils.ts @@ -7,7 +7,7 @@ */ import { workspaces } from '@angular-devkit/core'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { McpServer } from '@modelcontextprotocol/server'; import { AngularWorkspace } from '../../../utilities/config'; import { type Devserver } from '../devserver'; import { Host } from '../host'; @@ -44,6 +44,9 @@ export interface MockContextOptions { /** Initial set of projects to populate the mock workspace with. */ projects?: Record; + + /** Optional roots to configure in the mock context. */ + roots?: string[]; } /** @@ -75,6 +78,7 @@ export function createMockContext(options: MockContextOptions = {}): { logger: { warn: () => {} }, devservers: new Map(), host, + roots: options.roots, }; return { host, context, projects }; diff --git a/packages/angular/cli/src/commands/mcp/tools/ai-tutor.ts b/packages/angular/cli/src/commands/mcp/tools/ai-tutor.ts index 590d0940c747..c72f0fe42a12 100644 --- a/packages/angular/cli/src/commands/mcp/tools/ai-tutor.ts +++ b/packages/angular/cli/src/commands/mcp/tools/ai-tutor.ts @@ -6,8 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import aiTutorText from '../resources/ai-tutor.md'; import { declareTool } from './tool-registry'; export const AI_TUTOR_TOOL = declareTool({ @@ -37,23 +36,17 @@ with a new core identity and knowledge base. isReadOnly: true, isLocalOnly: true, factory: () => { - let aiTutorText: string; - - return async () => { - aiTutorText ??= await readFile(join(__dirname, '../resources/ai-tutor.md'), 'utf-8'); - - return { - content: [ - { - type: 'text', - text: aiTutorText, - annotations: { - audience: ['assistant'], - priority: 1.0, - }, + return async () => ({ + content: [ + { + type: 'text', + text: aiTutorText, + annotations: { + audience: ['assistant'], + priority: 1.0, }, - ], - }; - }; + }, + ], + }); }, }); diff --git a/packages/angular/cli/src/commands/mcp/tools/best-practices.ts b/packages/angular/cli/src/commands/mcp/tools/best-practices.ts index dca61eb700b3..d8a8d1251604 100644 --- a/packages/angular/cli/src/commands/mcp/tools/best-practices.ts +++ b/packages/angular/cli/src/commands/mcp/tools/best-practices.ts @@ -17,9 +17,11 @@ import { readFile, stat } from 'node:fs/promises'; import { createRequire } from 'node:module'; -import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { dirname, isAbsolute, relative, resolve } from 'node:path'; import { z } from 'zod'; import { VERSION } from '../../../utilities/version'; +import bundledBestPractices from '../resources/best-practices.md'; +import { isAllowedWorkspacePath } from '../workspace-utils'; import { type McpToolContext, declareTool } from './tool-registry'; const bestPracticesInputSchema = z.object({ @@ -58,15 +60,6 @@ that must be followed for any task involving the creation, analysis, or modifica factory: createBestPracticesHandler, }); -/** - * Retrieves the content of the generic best practices guide that is bundled with the CLI. - * This serves as a fallback when a version-specific guide cannot be found. - * @returns A promise that resolves to the string content of the bundled markdown file. - */ -async function getBundledBestPractices(): Promise { - return readFile(join(__dirname, '../resources/best-practices.md'), 'utf-8'); -} - /** * Attempts to find and read a version-specific best practices guide from the user's installed * version of `@angular/core`. It looks for a custom `angular` metadata property in the @@ -86,13 +79,36 @@ async function getBundledBestPractices(): Promise { * * @param workspacePath The absolute path to the user's `angular.json` file. * @param logger The MCP tool context logger for reporting warnings. + * @param server The MCP server context, used to enforce the client's declared roots. * @returns A promise that resolves to an object containing the guide's content and source, * or `undefined` if the guide could not be resolved. */ async function getVersionSpecificBestPractices( workspacePath: string, logger: McpToolContext['logger'], + server: McpToolContext['server'], ): Promise<{ content: string; source: string } | undefined> { + if (server) { + let isAllowed: boolean; + try { + isAllowed = await isAllowedWorkspacePath(server, workspacePath); + } catch (e) { + logger.warn( + `Failed to verify workspace path '${workspacePath}': ` + + `${e instanceof Error ? e.message : e}. Falling back to the bundled guide.`, + ); + + return undefined; + } + + if (!isAllowed) { + throw new Error( + `Workspace path is outside the allowed MCP roots: ${workspacePath}. ` + + "You can use 'list_projects' to find available workspaces.", + ); + } + } + // 1. Resolve the path to package.json let pkgJsonPath: string; try { @@ -175,16 +191,18 @@ async function getVersionSpecificBestPractices( * @param context The MCP tool context, containing the logger. * @returns An async function that serves as the tool's executor. */ -function createBestPracticesHandler({ logger }: McpToolContext) { - let bundledBestPractices: Promise; - +function createBestPracticesHandler({ logger, server }: McpToolContext) { return async (input: BestPracticesInput) => { let content: string | undefined; let source: string | undefined; // First, try to get the version-specific guide. if (input.workspacePath) { - const versionSpecific = await getVersionSpecificBestPractices(input.workspacePath, logger); + const versionSpecific = await getVersionSpecificBestPractices( + input.workspacePath, + logger, + server, + ); if (versionSpecific) { content = versionSpecific.content; source = versionSpecific.source; @@ -193,7 +211,7 @@ function createBestPracticesHandler({ logger }: McpToolContext) { // If the version-specific guide was not found for any reason, fall back to the bundled version. if (content === undefined) { - content = await (bundledBestPractices ??= getBundledBestPractices()); + content = bundledBestPractices; source = `bundled (CLI v${VERSION.full})`; } diff --git a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-start.ts b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-start.ts index 4e2155f500b7..72bf5440adcb 100644 --- a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-start.ts +++ b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-start.ts @@ -119,7 +119,7 @@ the first build completes. * **Consistent Ports**: If making multiple calls, it is recommended to reuse the port you got from the first call for subsequent ones. `, - isReadOnly: true, + isReadOnly: false, isLocalOnly: true, inputSchema: devserverStartToolInputSchema.shape, outputSchema: devserverStartToolOutputSchema.shape, diff --git a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-stop.ts b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-stop.ts index 669160695194..32b7d90269d2 100644 --- a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-stop.ts +++ b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-stop.ts @@ -69,7 +69,7 @@ Stops a running Angular development server ("ng serve") that was started with th time after this is called. However note that this is not a blocker for starting a new devserver. `, - isReadOnly: true, + isReadOnly: false, isLocalOnly: true, inputSchema: devserverStopToolInputSchema.shape, outputSchema: devserverStopToolOutputSchema.shape, diff --git a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file.ts b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file.ts index ebbc254d4d69..58f5842203a5 100644 --- a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file.ts +++ b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file.ts @@ -6,8 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; -import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; +import type { ServerContext } from '@modelcontextprotocol/server'; import type { SourceFile } from 'typescript'; import type { Host } from '../../host'; import { analyzeForUnsupportedZoneUses } from './analyze-for-unsupported-zone-uses'; @@ -22,7 +21,7 @@ const supportedStrategies: ReadonlySet = new Set(['OnPush', 'Default', ' export async function migrateSingleFile( sourceFile: SourceFile, host: Host, - extras: RequestHandlerExtra, + extras: ServerContext, ): Promise { const testBedSpecifier = await getImportSpecifier(sourceFile, '@angular/core/testing', 'TestBed'); const isTestFile = sourceFile.fileName.endsWith('.spec.ts') || !!testBedSpecifier; diff --git a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file_spec.ts b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file_spec.ts index 4f6337d8e434..8609977fc417 100644 --- a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file_spec.ts @@ -6,16 +6,17 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; -import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; +import type { ServerContext } from '@modelcontextprotocol/server'; import ts from 'typescript'; import { createMockHost } from '../../testing/test-utils'; import { migrateSingleFile } from './migrate-single-file'; const fakeExtras = { - sendDebugMessage: jasmine.createSpy(), - sendNotification: jasmine.createSpy(), -} as unknown as RequestHandlerExtra; + mcpReq: { + log: jasmine.createSpy(), + notify: jasmine.createSpy(), + }, +} as unknown as ServerContext; describe('migrateSingleFile', () => { const mockHost = createMockHost(); diff --git a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/send-debug-message.ts b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/send-debug-message.ts index b2f5436d497a..8e0c5fe9ca11 100644 --- a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/send-debug-message.ts +++ b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/send-debug-message.ts @@ -6,18 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; -import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; +import type { ServerContext } from '@modelcontextprotocol/server'; -export function sendDebugMessage( - message: string, - { sendNotification }: RequestHandlerExtra, -): void { - void sendNotification({ - method: 'notifications/message', - params: { - level: 'debug', - data: message, - }, - }); +export function sendDebugMessage(message: string, ctx: ServerContext): void { + void ctx.mcpReq.log('debug', message); } diff --git a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/zoneless-migration.ts b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/zoneless-migration.ts index 25c12aa83378..36fc7f91b5ae 100644 --- a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/zoneless-migration.ts +++ b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/zoneless-migration.ts @@ -6,8 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; -import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; +import type { ServerContext } from '@modelcontextprotocol/server'; import { join } from 'node:path'; import type { SourceFile } from 'typescript'; import { z } from 'zod'; @@ -57,7 +56,7 @@ change detection (a prerequisite for zoneless applications). export async function registerZonelessMigrationTool( fileOrDirPath: string, host: Host, - extras: RequestHandlerExtra, + extras: ServerContext, ) { let filesWithComponents, componentTestFiles, zoneFiles, categorizationErrors; try { @@ -114,7 +113,7 @@ export async function registerZonelessMigrationTool( async function discoverAndCategorizeFiles( fileOrDirPath: string, host: Host, - extras: RequestHandlerExtra, + extras: ServerContext, ) { const filePaths: string[] = []; const componentTestFiles = new Set(); @@ -175,7 +174,7 @@ async function discoverAndCategorizeFiles( async function categorizeFile( sourceFile: SourceFile, host: Host, - extras: RequestHandlerExtra, + extras: ServerContext, categorizedFiles: { filesWithComponents: Set; componentTestFiles: Set; @@ -216,11 +215,11 @@ async function categorizeFile( } async function rankComponentFilesForMigration( - { sendRequest }: RequestHandlerExtra, + ctx: ServerContext, componentFiles: SourceFile[], ): Promise { try { - const response = await sendRequest( + const response = await ctx.mcpReq.send( { method: 'sampling/createMessage', params: { diff --git a/packages/angular/cli/src/commands/mcp/tools/projects.ts b/packages/angular/cli/src/commands/mcp/tools/projects.ts index 57efa39c4aa2..5b9d15808b44 100644 --- a/packages/angular/cli/src/commands/mcp/tools/projects.ts +++ b/packages/angular/cli/src/commands/mcp/tools/projects.ts @@ -386,11 +386,9 @@ async function getProjectStyleLanguage( fullSourceRoot: string, ): Promise { const projectSchematics = project.extensions.schematics as - | Record> - | undefined; + Record> | undefined; const workspaceSchematics = workspace.extensions.schematics as - | Record> - | undefined; + Record> | undefined; // 1. Check for a project-specific schematic setting. let style = projectSchematics?.['@schematics/angular:component']?.['style']; @@ -566,7 +564,7 @@ function deduplicateSearchRoots(roots: string[]): string[] { return deduplicated; } -async function createListProjectsHandler({ server }: McpToolContext) { +async function createListProjectsHandler({ server, roots: configuredRoots }: McpToolContext) { return async () => { const workspaces: WorkspaceData[] = []; const parsingErrors: ParsingError[] = []; @@ -574,14 +572,16 @@ async function createListProjectsHandler({ server }: McpToolContext) { const seenPaths = new Set(); const versionCache = new Map(); - let searchRoots: string[]; + let searchRoots: string[] | undefined; const clientCapabilities = server.server.getClientCapabilities(); if (clientCapabilities?.roots) { const { roots } = await server.server.listRoots(); - searchRoots = roots?.map((r) => normalize(fileURLToPath(r.uri))) ?? []; - } else { - // Fallback to the current working directory if client does not support roots - searchRoots = [process.cwd()]; + searchRoots = roots?.map((r) => normalize(fileURLToPath(r.uri))); + } + + if (!searchRoots || searchRoots.length === 0) { + searchRoots = + configuredRoots && configuredRoots.length > 0 ? configuredRoots : [process.cwd()]; } searchRoots = deduplicateSearchRoots(searchRoots); diff --git a/packages/angular/cli/src/commands/mcp/tools/projects_spec.ts b/packages/angular/cli/src/commands/mcp/tools/projects_spec.ts index b45b1bbcb189..9c5e2d7d08f8 100644 --- a/packages/angular/cli/src/commands/mcp/tools/projects_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/projects_spec.ts @@ -89,4 +89,43 @@ describe('List Projects Tool', () => { expect(projects[0].targets).toEqual(['build', 'test', 'lint', 'e2e']); expect(projects[0].unitTestFramework).toBe('vitest'); }); + + it('should use configured roots when client roots capability is absent', async () => { + mockContext.server = { + server: { + getClientCapabilities: jasmine.createSpy('getClientCapabilities').and.returnValue({}), + }, + } as unknown as NonNullable[0]['server']>; + mockContext.roots = [allowedRoot]; + + const handler = await LIST_PROJECTS_TOOL.factory(mockContext); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (handler as any)({}); + + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent.workspaces.length).toBe(1); + expect(result.structuredContent.workspaces[0].projects[0].name).toBe('my-app'); + }); + + it('should fall back to configured roots when client supports roots but returns an empty list', async () => { + mockContext.server = { + server: { + getClientCapabilities: jasmine.createSpy('getClientCapabilities').and.returnValue({ + roots: { listChanged: false }, + }), + listRoots: jasmine.createSpy('listRoots').and.resolveTo({ + roots: [], + }), + }, + } as unknown as NonNullable[0]['server']>; + mockContext.roots = [allowedRoot]; + + const handler = await LIST_PROJECTS_TOOL.factory(mockContext); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (handler as any)({}); + + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent.workspaces.length).toBe(1); + expect(result.structuredContent.workspaces[0].projects[0].name).toBe('my-app'); + }); }); diff --git a/packages/angular/cli/src/commands/mcp/tools/run-target/build-target-strategy.ts b/packages/angular/cli/src/commands/mcp/tools/run-target/build-target-strategy.ts index 1fbd8f83e47a..2e280927f466 100644 --- a/packages/angular/cli/src/commands/mcp/tools/run-target/build-target-strategy.ts +++ b/packages/angular/cli/src/commands/mcp/tools/run-target/build-target-strategy.ts @@ -29,7 +29,7 @@ export class BuildTargetStrategy implements TargetStrategy { ): Promise { const args = ['build', input.projectName]; if (input.configuration) { - args.push('-c', input.configuration); + args.push(`--configuration=${input.configuration}`); } args.push(...serializeOptions(input.options)); diff --git a/packages/angular/cli/src/commands/mcp/tools/run-target/generic-target-strategy.ts b/packages/angular/cli/src/commands/mcp/tools/run-target/generic-target-strategy.ts index e2cbc816c330..365253131248 100644 --- a/packages/angular/cli/src/commands/mcp/tools/run-target/generic-target-strategy.ts +++ b/packages/angular/cli/src/commands/mcp/tools/run-target/generic-target-strategy.ts @@ -46,7 +46,7 @@ export class GenericTargetStrategy implements TargetStrategy { } if (input.configuration) { - args.push('-c', input.configuration); + args.push(`--configuration=${input.configuration}`); } let options = input.options; diff --git a/packages/angular/cli/src/commands/mcp/tools/run-target/run-target_spec.ts b/packages/angular/cli/src/commands/mcp/tools/run-target/run-target_spec.ts index 67b1fffef27e..91ccbac4584a 100644 --- a/packages/angular/cli/src/commands/mcp/tools/run-target/run-target_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/run-target/run-target_spec.ts @@ -42,7 +42,7 @@ describe('Run Target Tool', () => { mockContext.workspace.extensions['defaultProject'] = 'my-app'; await runTarget({ target: 'build', configuration: 'production' }, mockContext); expect(mockHost.executeNgCommand).toHaveBeenCalledWith( - ['build', 'my-app', '-c', 'production'], + ['build', 'my-app', '--configuration=production'], { cwd: '/test', }, @@ -53,7 +53,7 @@ describe('Run Target Tool', () => { mockContext.workspace.extensions['defaultProject'] = 'my-app'; await runTarget({ target: 'storybook', configuration: 'docs' }, mockContext); expect(mockHost.executeNgCommand).toHaveBeenCalledWith( - ['run', 'my-app:storybook', '-c', 'docs'], + ['run', 'my-app:storybook', '--configuration=docs'], { cwd: '/test' }, ); }); diff --git a/packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy.ts b/packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy.ts index 77ca5797e71c..1023cdd7956c 100644 --- a/packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy.ts +++ b/packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy.ts @@ -28,7 +28,7 @@ export class UnitTestTargetStrategy implements TargetStrategy { ): Promise { const args = ['test', input.projectName]; if (input.configuration) { - args.push('-c', input.configuration); + args.push(`--configuration=${input.configuration}`); } const builder = input.targetDefinition?.builder; diff --git a/packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy_spec.ts b/packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy_spec.ts index f1467f0002c9..4e63eed148a0 100644 --- a/packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy_spec.ts @@ -65,7 +65,15 @@ describe('UnitTestTargetStrategy', () => { ); expect(mockHost.executeNgCommand).toHaveBeenCalledWith( - ['test', 'my-app', '-c', 'ci', '--browsers', 'ChromeHeadless', '--watch', 'false'], + [ + 'test', + 'my-app', + '--configuration=ci', + '--browsers', + 'ChromeHeadless', + '--watch', + 'false', + ], { cwd: '/test' }, ); }); diff --git a/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts b/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts index 5af40a4e98b5..ae50f64d8fa2 100644 --- a/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts +++ b/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts @@ -6,27 +6,34 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { McpServer, ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types'; -import type { ZodRawShape } from 'zod'; +import type { + McpServer, + ServerContext, + ToolAnnotations, + ToolCallback, +} from '@modelcontextprotocol/server'; +import { type ZodRawShape, z } from 'zod'; import type { AngularWorkspace } from '../../../utilities/config'; import type { Devserver } from '../devserver'; import type { Host } from '../host'; -type ToolConfig = Parameters[1]; - export interface McpToolContext { server: McpServer; workspace?: AngularWorkspace; logger: { warn(text: string): void }; - exampleDatabasePath?: string; devservers: Map; host: Host; + roots?: string[]; } +export type McpToolCallback = ( + args: z.infer>, + ctx: ServerContext, +) => ReturnType; + export type McpToolFactory = ( context: McpToolContext, -) => ToolCallback | Promise>; +) => McpToolCallback | Promise>; export interface McpToolDeclaration { name: string; @@ -75,6 +82,14 @@ export async function registerTools( config.annotations.openWorldHint = !isLocalOnly; } - server.registerTool(name, config, handler); + server.registerTool( + name, + { + ...config, + inputSchema: config.inputSchema ? z.object(config.inputSchema) : undefined, + outputSchema: config.outputSchema ? z.object(config.outputSchema) : undefined, + }, + handler, + ); } } diff --git a/packages/angular/cli/src/commands/mcp/workspace-utils.ts b/packages/angular/cli/src/commands/mcp/workspace-utils.ts index 6cc245ff1dbc..94c4c55655c0 100644 --- a/packages/angular/cli/src/commands/mcp/workspace-utils.ts +++ b/packages/angular/cli/src/commands/mcp/workspace-utils.ts @@ -110,7 +110,7 @@ async function getAllowedWorkspaceRoots(server: McpToolContext['server']): Promi .filter((root): root is string => root !== null); } -async function isAllowedWorkspacePath( +export async function isAllowedWorkspacePath( server: McpToolContext['server'], workspacePath: string, ): Promise { diff --git a/packages/angular/cli/src/commands/new/cli.ts b/packages/angular/cli/src/commands/new/cli.ts index 6e6545e66421..a873fefeefe0 100644 --- a/packages/angular/cli/src/commands/new/cli.ts +++ b/packages/angular/cli/src/commands/new/cli.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import { join } from 'node:path'; import { Argv } from 'yargs'; import { CommandModuleImplementation, @@ -21,6 +20,7 @@ import { } from '../../command-builder/schematics-command-module'; import { VERSION } from '../../utilities/version'; import { RootCommands } from '../command-config'; +import longDescription from './long-description.md'; interface NewCommandArgs extends SchematicsCommandArgs { collection?: string; @@ -37,7 +37,7 @@ export default class NewCommandModule command = 'new [name]'; aliases = RootCommands['new'].aliases; describe = 'Creates a new Angular workspace.'; - longDescriptionPath = join(__dirname, 'long-description.md'); + override longDescription = longDescription; override async builder(argv: Argv): Promise> { const localYargs = (await super.builder(argv)).option('collection', { diff --git a/packages/angular/cli/src/commands/run/cli.ts b/packages/angular/cli/src/commands/run/cli.ts index aa12cd0158f7..134910ef2885 100644 --- a/packages/angular/cli/src/commands/run/cli.ts +++ b/packages/angular/cli/src/commands/run/cli.ts @@ -7,7 +7,6 @@ */ import { Target } from '@angular-devkit/architect'; -import { join } from 'node:path'; import { Argv } from 'yargs'; import { ArchitectBaseCommandModule } from '../../command-builder/architect-base-command-module'; import { @@ -17,6 +16,7 @@ import { Options, OtherOptions, } from '../../command-builder/command-module'; +import longDescription from './long-description.md'; export interface RunCommandArgs { target: string; @@ -31,7 +31,7 @@ export default class RunCommandModule command = 'run '; describe = 'Runs an Architect target with an optional custom builder configuration defined in your project.'; - longDescriptionPath = join(__dirname, 'long-description.md'); + override longDescription = longDescription; async builder(argv: Argv): Promise> { const { jsonHelp, getYargsCompletions, help } = this.context.args.options; diff --git a/packages/angular/cli/src/commands/serve/cli.ts b/packages/angular/cli/src/commands/serve/cli.ts index 3b38fa122acd..6448c48b9484 100644 --- a/packages/angular/cli/src/commands/serve/cli.ts +++ b/packages/angular/cli/src/commands/serve/cli.ts @@ -18,5 +18,4 @@ export default class ServeCommandModule command = 'serve [project]'; aliases = RootCommands['serve'].aliases; describe = 'Builds and serves your application, rebuilding on file changes.'; - longDescriptionPath?: string | undefined; } diff --git a/packages/angular/cli/src/commands/test/cli.ts b/packages/angular/cli/src/commands/test/cli.ts index 600e9f41f517..c08789714c1c 100644 --- a/packages/angular/cli/src/commands/test/cli.ts +++ b/packages/angular/cli/src/commands/test/cli.ts @@ -6,10 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ -import { join } from 'node:path'; import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; import { CommandModuleImplementation } from '../../command-builder/command-module'; import { RootCommands } from '../command-config'; +import longDescription from './long-description.md'; export default class TestCommandModule extends ArchitectCommandModule @@ -19,5 +19,5 @@ export default class TestCommandModule command = 'test [project]'; aliases = RootCommands['test'].aliases; describe = 'Runs unit tests in a project.'; - longDescriptionPath = join(__dirname, 'long-description.md'); + override longDescription = longDescription; } diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts index b886c287f2ec..fd0eeca166a3 100644 --- a/packages/angular/cli/src/commands/update/cli.ts +++ b/packages/angular/cli/src/commands/update/cli.ts @@ -9,7 +9,7 @@ import { NodeWorkflow } from '@angular-devkit/schematics/tools'; import { Listr } from 'listr2'; import { existsSync, promises as fs } from 'node:fs'; -import { createRequire } from 'node:module'; +import { Module, createRequire } from 'node:module'; import * as path from 'node:path'; import npa from 'npm-package-arg'; import { Argv } from 'yargs'; @@ -24,6 +24,7 @@ import type { InstalledPackage, PackageManager, PackageManifest } from '../../pa import { colors } from '../../utilities/color'; import { disableVersionCheck } from '../../utilities/environment-options'; import { assertIsError } from '../../utilities/error'; +import longDescription from './long-description.md'; import { UpdatePlan, applyUpdatePlan, @@ -59,11 +60,11 @@ class CommandError extends Error {} export default class UpdateCommandModule extends CommandModule { override scope = CommandScope.In; protected override shouldReportAnalytics = false; - private readonly resolvePaths = [__dirname, this.context.root]; + private readonly resolvePaths = [import.meta.dirname, this.context.root]; command = 'update [packages..]'; describe = 'Updates your workspace and its dependencies. See https://update.angular.dev/.'; - longDescriptionPath = path.join(__dirname, 'long-description.md'); + override longDescription = longDescription; builder(localYargs: Argv): Argv { return localYargs @@ -236,7 +237,7 @@ export default class UpdateCommandModule extends CommandModule favor @schematics/update from this package + // import.meta.dirname -> favor @schematics/update from this package // Otherwise, use packages from the active workspace (migrations) resolvePaths: this.resolvePaths, schemaValidation: true, @@ -604,8 +605,7 @@ export default class UpdateCommandModule extends CommandModule/package.json` after installation, + * we ensure that any migration collections defined by the package are discovered and queued. + */ +export async function resolveFallbackMigrations( + workspaceRoot: string, + plan: UpdatePlan, +): Promise<{ package: string; collection: string; from: string; to: string }[]> { + const migrations = [...plan.migrationsToRun]; + const existingMigrationPackages = new Set(migrations.map((m) => m.package)); + + for (const [packageName, targetVersion] of plan.packagesToUpdate) { + if (existingMigrationPackages.has(packageName)) { + continue; + } + + const packageJsonPath = findPackageJson(workspaceRoot, packageName); + if (packageJsonPath) { + try { + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); + const ngUpdate = packageJson?.['ng-update']; + if (ngUpdate && typeof ngUpdate === 'object' && typeof ngUpdate.migrations === 'string') { + const installedVersion = plan.packageInfoMap.get(packageName)?.installed.version; + if (installedVersion) { + migrations.push({ + package: packageName, + collection: ngUpdate.migrations, + from: installedVersion, + to: targetVersion, + }); + } + } + } catch { + // Ignore read/parse errors for optional fallback + } + } + } + + return migrations; +} diff --git a/packages/angular/cli/src/commands/update/cli_spec.ts b/packages/angular/cli/src/commands/update/cli_spec.ts new file mode 100644 index 000000000000..bae00d3dba24 --- /dev/null +++ b/packages/angular/cli/src/commands/update/cli_spec.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import path from 'path'; +import { resolveFallbackMigrations } from './cli'; +import type { PackageVersionInfo, UpdatePlan } from './update-resolver'; + +describe('resolveFallbackMigrations', () => { + let tempRoot: string; + let pkgDir: string; + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(path.join(baseTmpDir, 'angular-cli-update-cli-test-')); + pkgDir = path.join(tempRoot, 'node_modules/@company/library-name'); + await mkdir(pkgDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + + it('discovers migrations from installed package.json when omitted from plan.migrationsToRun', async () => { + await writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify({ + name: '@company/library-name', + version: '21.2.0-next.1', + 'ng-update': { + migrations: './schematics/migration.json', + }, + }), + 'utf8', + ); + + const plan: UpdatePlan = { + packagesToUpdate: new Map([['@company/library-name', '21.2.0-next.1']]), + migrationsToRun: [], + packageInfoMap: new Map([ + [ + '@company/library-name', + { + name: '@company/library-name', + npmPackageJson: { + name: '@company/library-name', + versions: ['21.1.0', '21.2.0-next.1'], + 'dist-tags': {}, + }, + installed: { + version: '21.1.0' as unknown as PackageVersionInfo['version'], + packageJson: { name: '@company/library-name', version: '21.1.0' }, + updateMetadata: { packageGroup: {}, requirements: {} }, + }, + packageJsonRange: '^21.1.0', + }, + ], + ]), + registryClient: undefined as unknown as UpdatePlan['registryClient'], + }; + + const migrations = await resolveFallbackMigrations(tempRoot, plan); + + expect(migrations).toEqual([ + { + package: '@company/library-name', + collection: './schematics/migration.json', + from: '21.1.0', + to: '21.2.0-next.1', + }, + ]); + }); + + it('does not duplicate migration if package is already in plan.migrationsToRun', async () => { + await writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify({ + name: '@company/library-name', + version: '21.2.0-next.1', + 'ng-update': { + migrations: './schematics/migration.json', + }, + }), + 'utf8', + ); + + const plan: UpdatePlan = { + packagesToUpdate: new Map([['@company/library-name', '21.2.0-next.1']]), + migrationsToRun: [ + { + package: '@company/library-name', + collection: './schematics/migration.json', + from: '21.1.0', + to: '21.2.0-next.1', + }, + ], + packageInfoMap: new Map(), + registryClient: undefined as unknown as UpdatePlan['registryClient'], + }; + + const migrations = await resolveFallbackMigrations(tempRoot, plan); + + expect(migrations).toHaveSize(1); + }); + + it('returns unchanged migrations when package has no ng-update field on disk', async () => { + await writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify({ + name: '@company/library-name', + version: '21.2.0-next.1', + }), + 'utf8', + ); + + const plan: UpdatePlan = { + packagesToUpdate: new Map([['@company/library-name', '21.2.0-next.1']]), + migrationsToRun: [], + packageInfoMap: new Map(), + registryClient: undefined as unknown as UpdatePlan['registryClient'], + }; + + const migrations = await resolveFallbackMigrations(tempRoot, plan); + + expect(migrations).toHaveSize(0); + }); +}); diff --git a/packages/angular/cli/src/commands/update/update-resolver.ts b/packages/angular/cli/src/commands/update/update-resolver.ts index 7b75ab7a9e20..995762d92bc9 100644 --- a/packages/angular/cli/src/commands/update/update-resolver.ts +++ b/packages/angular/cli/src/commands/update/update-resolver.ts @@ -942,11 +942,7 @@ export async function resolveUserUpdatePlan( }; if (packages.size === 0) { - await Promise.all( - Array.from(npmDeps.keys()).map(async (depName) => { - await getOrFetchPackageMetadata(depName); - }), - ); + await Promise.all(Array.from(npmDeps.keys(), (depName) => getOrFetchPackageMetadata(depName))); } else { let lastPackagesSize; do { @@ -999,12 +995,13 @@ export async function resolveUserUpdatePlan( } while (packages.size > lastPackagesSize); } + const isListingUpdates = packages.size === 0; const packageInfoEntries = await Promise.all( Array.from(npmDeps.keys(), async (depName) => { const isUpdating = packages.has(depName); const localPkgJson = getInstalledPackageJson(depName, workspaceRoot); - if (isUpdating || !localPkgJson) { + if (isListingUpdates || isUpdating || !localPkgJson) { const metadata = await getOrFetchPackageMetadata(depName); if (metadata) { const info = await _buildPackageInfo( diff --git a/packages/angular/cli/src/commands/update/update-resolver_spec.ts b/packages/angular/cli/src/commands/update/update-resolver_spec.ts index e18cba641016..9ced5829587e 100644 --- a/packages/angular/cli/src/commands/update/update-resolver_spec.ts +++ b/packages/angular/cli/src/commands/update/update-resolver_spec.ts @@ -289,6 +289,29 @@ describe('UpdateResolver', () => { expect(plan.packagesToUpdate.size).toBe(0); }); + it('includes registry metadata when listing updates with locked dependency versions in package.json', async () => { + createMockWorkspace( + { + name: 'blah', + dependencies: { + '@angular-devkit-tests/update-base': '1.0.0', + }, + }, + { + '@angular-devkit-tests/update-base': { version: '1.0.0' }, + }, + ); + + const plan = await resolvePlan({ + packages: [], + workspaceRoot: tempRoot, + }); + + const info = plan.packageInfoMap.get('@angular-devkit-tests/update-base'); + expect(info?.npmPackageJson['dist-tags']?.['latest']).toBe('1.1.0'); + expect(info?.npmPackageJson.versions).toContain('1.1.0'); + }); + it('should not error with yarn 2.0 protocols', async () => { createMockWorkspace( { diff --git a/packages/angular/cli/src/commands/update/utilities/git.ts b/packages/angular/cli/src/commands/update/utilities/git.ts index 0998c9c61fff..1714846117d9 100644 --- a/packages/angular/cli/src/commands/update/utilities/git.ts +++ b/packages/angular/cli/src/commands/update/utilities/git.ts @@ -60,7 +60,7 @@ export function checkCleanGit(root: string): boolean { return false; } } - } catch {} // eslint-disable-line no-empty + } catch {} return true; } diff --git a/packages/angular/cli/src/commands/update/utilities/migration.ts b/packages/angular/cli/src/commands/update/utilities/migration.ts index 21331364bc2f..83a1492eaa10 100644 --- a/packages/angular/cli/src/commands/update/utilities/migration.ts +++ b/packages/angular/cli/src/commands/update/utilities/migration.ts @@ -13,10 +13,11 @@ import { FileSystemSchematicDescription, NodeWorkflow, } from '@angular-devkit/schematics/tools'; +import { figures } from 'listr2'; import { SpawnSyncReturns } from 'node:child_process'; import * as semver from 'semver'; import { subscribeToWorkflow } from '../../../command-builder/utilities/schematic-workflow'; -import { colors, figures } from '../../../utilities/color'; +import { colors } from '../../../utilities/color'; import { assertIsError } from '../../../utilities/error'; import { writeErrorToLogFile } from '../../../utilities/log-file'; import { formatFiles } from '../../../utilities/prettier'; diff --git a/packages/angular/cli/src/commands/version/cli.ts b/packages/angular/cli/src/commands/version/cli.ts index 205f0bc7e55e..1a79b8b76eef 100644 --- a/packages/angular/cli/src/commands/version/cli.ts +++ b/packages/angular/cli/src/commands/version/cli.ts @@ -37,7 +37,6 @@ export default class VersionCommandModule command = 'version'; aliases = RootCommands['version'].aliases; describe = 'Outputs Angular CLI version.'; - longDescriptionPath?: string | undefined; /** * Builds the command-line options for the `ng version` command. diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 32f63e51deac..234b54851232 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -724,8 +724,15 @@ export class PackageManager { } } + // To prevent Yarn modern from traversing up the directory tree and failing because the temporary + // directory is not part of the project's workspace, write an empty `yarn.lock` to act as a project boundary. + if (this.name === 'yarn') { + await this.host.writeFile(join(workingDirectory, 'yarn.lock'), ''); + } + // Copy configuration files if the package manager requires it (e.g., bun, yarn). if (this.descriptor.copyConfigFromProject) { + let copiedYarnConfig = false; for (const configFile of this.descriptor.configFiles) { try { const configPath = join(this.cwd, configFile); @@ -734,10 +741,20 @@ export class PackageManager { content = sanitizeYarnRc(content); } await this.host.writeFile(join(workingDirectory, configFile), content); + if (this.name === 'yarn') { + copiedYarnConfig = true; + } } catch { // Ignore missing config files. } } + + if (this.name === 'yarn' && !copiedYarnConfig) { + await this.host.writeFile( + join(workingDirectory, '.yarnrc.yml'), + 'nodeLinker: node-modules\n', + ); + } } const flags = [options.ignoreScripts ? this.descriptor.ignoreScriptsFlag : ''].filter( diff --git a/packages/angular/cli/src/package-managers/package-manager_spec.ts b/packages/angular/cli/src/package-managers/package-manager_spec.ts index 83709af7182b..d500a435d0df 100644 --- a/packages/angular/cli/src/package-managers/package-manager_spec.ts +++ b/packages/angular/cli/src/package-managers/package-manager_spec.ts @@ -192,6 +192,10 @@ describe('PackageManager', () => { '/tmp/project/node_modules/angular-cli-tmp-packages-abc/pnpm-workspace.yaml', '', ); + expect(writeFileSpy).not.toHaveBeenCalledWith( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc/yarn.lock', + '', + ); }); it('should copy and sanitize .yarnrc.yml when package manager is yarn and it exists', async () => { @@ -244,6 +248,10 @@ describe('PackageManager', () => { 'nodeLinker: node-modules', ].join('\n'); + expect(writeFileSpy).toHaveBeenCalledWith( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc/yarn.lock', + '', + ); expect(writeFileSpy).toHaveBeenCalledWith( '/tmp/project/node_modules/angular-cli-tmp-packages-abc/.yarnrc.yml', expectedYarnRcContent, @@ -254,6 +262,45 @@ describe('PackageManager', () => { ); }); + it('should write empty yarn.lock and default .yarnrc.yml when package manager is yarn and config does not exist', async () => { + const yarnDescriptor = SUPPORTED_PACKAGE_MANAGERS['yarn']; + const testHost = new MockHost({ + '/tmp/project/node_modules': true, + }); + const pm = new PackageManager(testHost, '/tmp/project', yarnDescriptor); + + const createTempDirectorySpy = spyOn(testHost, 'createTempDirectory').and.resolveTo( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc', + ); + const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo(); + + spyOn(testHost, 'readFile').and.callFake(async (filePath) => { + if (filePath.replace(/\\/g, '/').endsWith('package.json')) { + return JSON.stringify({ packageManager: 'yarn@4.4.1' }); + } + throw new Error(`ENOENT: no such file or directory, open '${filePath}'`); + }); + spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '4.4.1', stderr: '' }); + + const { workingDirectory } = await pm.acquireTempPackage('foo@1.0.0'); + + expect(workingDirectory).toBe('/tmp/project/node_modules/angular-cli-tmp-packages-abc'); + expect(createTempDirectorySpy).toHaveBeenCalledWith('/tmp/project/node_modules'); + + expect(writeFileSpy).toHaveBeenCalledWith( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc/yarn.lock', + '', + ); + expect(writeFileSpy).toHaveBeenCalledWith( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc/.yarnrc.yml', + 'nodeLinker: node-modules\n', + ); + expect(writeFileSpy).toHaveBeenCalledWith( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json', + JSON.stringify({ packageManager: 'yarn@4.4.1' }, null, 2), + ); + }); + it('should copy packageManager field to temp package.json if version is resolved', async () => { const npmDescriptor = SUPPORTED_PACKAGE_MANAGERS['npm']; const testHost = new MockHost({ diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index 03774a0f2478..9fa915ed9ee9 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -262,13 +262,26 @@ function isValidManifest(obj: unknown): obj is PackageManifest { return false; } - const record = obj as Record; - const name = record.name; - const version = record.version; + const { name, version } = obj as Record; return typeof name === 'string' && typeof version === 'string' && valid(version) !== null; } +function isValidMetadata(obj: unknown): obj is PackageMetadata { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + const { name, versions, 'dist-tags': distTags } = obj as Record; + + return ( + typeof name === 'string' && + Array.isArray(versions) && + typeof distTags === 'object' && + distTags !== null + ); +} + /** * Parses the output of `npm view` or a compatible command to get a package manifest. * @param stdout The standard output of the command. @@ -340,7 +353,28 @@ export function parseNpmLikeMetadata(stdout: string, logger?: Logger): PackageMe return null; } - return JSON.parse(stdout); + const result = JSON.parse(stdout); + + // npm 12+ `npm view --json` always returns an array of objects. + if (Array.isArray(result)) { + for (const item of result) { + if (isValidMetadata(item)) { + return item; + } + } + + logger?.debug(' No valid metadata found in the array.'); + + return null; + } + + if (!isValidMetadata(result)) { + logger?.debug(' Parsed JSON is not valid metadata (missing name, versions, or dist-tags).'); + + return null; + } + + return result; } /** @@ -479,9 +513,16 @@ export function parseNpmLikeError(output: string, logger?: Logger): ErrorInfo | return null; } - // Attempt to parse as JSON first (common for pnpm, modern yarn, bun) + // Attempt to parse as JSON first (common for pnpm, modern yarn, bun, npm 12) try { - const jsonError = JSON.parse(output); + let jsonError = JSON.parse(output); + if (Array.isArray(jsonError)) { + jsonError = jsonError[0]; + } + if (jsonError && typeof jsonError === 'object' && 'error' in jsonError) { + jsonError = jsonError.error; + } + if ( jsonError && typeof jsonError.code === 'string' && diff --git a/packages/angular/cli/src/package-managers/parsers_spec.ts b/packages/angular/cli/src/package-managers/parsers_spec.ts index dff0adfc2726..049aeaec9129 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -12,6 +12,7 @@ import { parseNpmLikeDependencies, parseNpmLikeError, parseNpmLikeManifest, + parseNpmLikeMetadata, parsePnpmReleaseAge, parseYarnClassicDependencies, parseYarnClassicError, @@ -231,6 +232,56 @@ describe('parsers', () => { const error = parseNpmLikeError('An unexpected error occurred.'); expect(error).toBeNull(); }); + + it('should parse a structured JSON error wrapped in an error property (npm 12+ format)', () => { + const stdout = JSON.stringify({ + error: { + code: 'E404', + summary: 'Not Found', + detail: 'Package not found.', + }, + }); + const error = parseNpmLikeError(stdout); + expect(error).toEqual({ + code: 'E404', + summary: 'Not Found', + detail: 'Package not found.', + }); + }); + + it('should parse a structured JSON error wrapped in an array', () => { + const stdout = JSON.stringify([ + { + code: 'E404', + summary: 'Not Found', + detail: 'Package not found.', + }, + ]); + const error = parseNpmLikeError(stdout); + expect(error).toEqual({ + code: 'E404', + summary: 'Not Found', + detail: 'Package not found.', + }); + }); + + it('should parse a structured JSON error wrapped in both an array and an error property', () => { + const stdout = JSON.stringify([ + { + error: { + code: 'E404', + summary: 'Not Found', + detail: 'Package not found.', + }, + }, + ]); + const error = parseNpmLikeError(stdout); + expect(error).toEqual({ + code: 'E404', + summary: 'Not Found', + detail: 'Package not found.', + }); + }); }); describe('parseNpmLikeManifest', () => { @@ -295,6 +346,66 @@ describe('parsers', () => { }); }); + describe('parseNpmLikeMetadata', () => { + it('should parse a single metadata object', () => { + const stdout = JSON.stringify({ + name: 'foo', + 'dist-tags': { latest: '1.0.0' }, + versions: ['1.0.0'], + }); + expect(parseNpmLikeMetadata(stdout)).toEqual({ + name: 'foo', + 'dist-tags': { latest: '1.0.0' }, + versions: ['1.0.0'], + }); + }); + + it('should parse metadata from an array (npm 12+ format)', () => { + const stdout = JSON.stringify([ + { + name: 'foo', + 'dist-tags': { latest: '1.0.0' }, + versions: ['1.0.0'], + }, + ]); + expect(parseNpmLikeMetadata(stdout)).toEqual({ + name: 'foo', + 'dist-tags': { latest: '1.0.0' }, + versions: ['1.0.0'], + }); + }); + + it('should return the first valid metadata from an array', () => { + const stdout = JSON.stringify([ + { name: 'foo' }, // Invalid (missing versions and dist-tags) + { + name: 'foo', + 'dist-tags': { latest: '1.0.0' }, + versions: ['1.0.0'], + }, + ]); + expect(parseNpmLikeMetadata(stdout)).toEqual({ + name: 'foo', + 'dist-tags': { latest: '1.0.0' }, + versions: ['1.0.0'], + }); + }); + + it('should return null if no valid metadata is found in an array', () => { + const stdout = JSON.stringify([{ name: 'foo' }, { versions: ['1.0.0'] }]); + expect(parseNpmLikeMetadata(stdout)).toBeNull(); + }); + + it('should return null for invalid single object', () => { + const stdout = JSON.stringify({ name: 'foo' }); // Missing versions and dist-tags + expect(parseNpmLikeMetadata(stdout)).toBeNull(); + }); + + it('should return null for empty stdout', () => { + expect(parseNpmLikeMetadata('')).toBeNull(); + }); + }); + describe('parseYarnClassicManifest', () => { it('should parse a valid manifest', () => { const stdout = JSON.stringify({ diff --git a/packages/angular/cli/src/typings.d.ts b/packages/angular/cli/src/typings.d.ts new file mode 100644 index 000000000000..43b81c1b0229 --- /dev/null +++ b/packages/angular/cli/src/typings.d.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +declare module '*.md' { + const content: string; + export default content; +} + +declare module '#version' { + export const VERSION: string; + export const SUPPORTED_NODE_VERSIONS: string; + export const supportedNodeVersions: string[]; + export function isNodeVersionSupported( + currentVersion?: string, + supportedVersions?: string[], + ): boolean; + export function isNodeVersionRunnable( + currentVersion?: string, + supportedVersions?: string[], + ): boolean; +} diff --git a/packages/angular/cli/src/utilities/color.ts b/packages/angular/cli/src/utilities/color.ts index 3915d99ce248..13333dbc4b57 100644 --- a/packages/angular/cli/src/utilities/color.ts +++ b/packages/angular/cli/src/utilities/color.ts @@ -7,8 +7,23 @@ */ import { WriteStream } from 'node:tty'; +import { styleText } from 'node:util'; -export { color as colors, figures } from 'listr2'; +export const colors = Object.freeze({ + black: (text: string) => styleText('black', text), + blue: (text: string) => styleText('blue', text), + bold: (text: string) => styleText('bold', text), + cyan: (text: string) => styleText('cyan', text), + dim: (text: string) => styleText('dim', text), + gray: (text: string) => styleText('gray', text), + green: (text: string) => styleText('green', text), + italic: (text: string) => styleText('italic', text), + magenta: (text: string) => styleText('magenta', text), + red: (text: string) => styleText('red', text), + underline: (text: string) => styleText('underline', text), + white: (text: string) => styleText('white', text), + yellow: (text: string) => styleText('yellow', text), +}); export function supportColor(stream: NodeJS.WritableStream = process.stdout): boolean { if (stream instanceof WriteStream) { diff --git a/packages/angular/cli/src/utilities/config.ts b/packages/angular/cli/src/utilities/config.ts index 7b9188bb3d89..6aa762aa264f 100644 --- a/packages/angular/cli/src/utilities/config.ts +++ b/packages/angular/cli/src/utilities/config.ts @@ -11,7 +11,7 @@ import { existsSync, promises as fs } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { PackageManager } from '../../lib/config/workspace-schema'; -import { findUp, findUpSync } from './find-up'; +import { findUp } from './find-up'; import { JSONFile, readAndParseJson } from './json-file'; function isJsonObject(value: json.JsonValue | undefined): value is json.JsonObject { @@ -47,7 +47,14 @@ function createWorkspaceHost(): workspaces.WorkspaceHost { }; } -export const workspaceSchemaPath = path.join(__dirname, '../../lib/config/schema.json'); +const currentDirectory = import.meta.dirname; + +// In bundled ESM output, files are located in `lib/` directly adjacent to `config/schema.json` +// whereas in unbundled development/tests, `config.js` is in `src/utilities/`. +const bundledSchemaPath = path.join(currentDirectory, 'config/schema.json'); +export const workspaceSchemaPath = existsSync(bundledSchemaPath) + ? bundledSchemaPath + : path.join(currentDirectory, '../../lib/config/schema.json'); const configNames = ['angular.json', '.angular.json']; const globalFileName = '.angular-config.json'; @@ -76,7 +83,7 @@ async function projectFilePath(projectPath?: string): Promise { return ( (projectPath && (await findUp(configNames, projectPath))) || (await findUp(configNames, process.cwd())) || - (await findUp(configNames, __dirname)) + (await findUp(configNames, currentDirectory)) ); } diff --git a/packages/angular/cli/src/utilities/node-version.ts b/packages/angular/cli/src/utilities/node-version.ts index 7bed934428c8..87eb4fa377b6 100644 --- a/packages/angular/cli/src/utilities/node-version.ts +++ b/packages/angular/cli/src/utilities/node-version.ts @@ -11,60 +11,34 @@ * @important This file must not import any other modules. */ -/** - * The supported Node.js version for the Angular CLI. - */ - -const SUPPORTED_NODE_VERSIONS = '0.0.0-ENGINES-NODE'; - -/** - * The supported Node.js versions. - */ -export const supportedNodeVersions = SUPPORTED_NODE_VERSIONS.replace(/[\^~<>=]/g, '') - .split('||') - .map((v) => v.trim()); - -/** - * Checks if the current Node.js version is supported. - * @returns `true` if the current Node.js version is supported, `false` otherwise. - */ -export function isNodeVersionSupported(): boolean { - if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0') { - // Unlike `pkg_npm`, `ts_library` which is used to run unit tests does not support substitutions. - return true; - } +import { SUPPORTED_NODE_VERSIONS, supportedNodeVersions } from '#version'; - const [processMajor, processMinor, processPatch] = process.versions.node - .split('.', 3) - .map((part) => Number(part)); - - for (const version of supportedNodeVersions) { - const [major, minor, patch] = version.split('.', 3).map((part) => Number(part)); - if ( - (major === processMajor && processMinor === minor && processPatch >= patch) || - (major === processMajor && processMinor > minor) - ) { - return true; - } - } - - return false; -} +export { + SUPPORTED_NODE_VERSIONS, + supportedNodeVersions, + isNodeVersionSupported, + isNodeVersionRunnable, +} from '#version'; /** * Checks if the current Node.js version is the minimum supported version. + * @param currentVersion Optional Node.js version string to check. Defaults to `process.versions.node`. + * @param supportedVersions Optional supported versions array. Defaults to `supportedNodeVersions`. * @returns `true` if the current Node.js version is the minimum supported version, `false` otherwise. */ -export function isNodeVersionMinSupported(): boolean { - if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0') { +export function isNodeVersionMinSupported( + currentVersion = process.versions.node, + supportedVersions = supportedNodeVersions, +): boolean { + if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0' && currentVersion === process.versions.node) { // Unlike `pkg_npm`, `ts_library` which is used to run unit tests does not support substitutions. return true; } - const [processMajor, processMinor, processPatch] = process.versions.node + const [processMajor, processMinor, processPatch] = currentVersion .split('.', 3) .map((part) => Number(part)); - const [major, minor, patch] = supportedNodeVersions[0].split('.', 3).map((part) => Number(part)); + const [major, minor, patch] = supportedVersions[0].split('.', 3).map((part) => Number(part)); return ( processMajor > major || diff --git a/packages/angular/cli/src/utilities/node-version_spec.ts b/packages/angular/cli/src/utilities/node-version_spec.ts new file mode 100644 index 000000000000..9426521faf65 --- /dev/null +++ b/packages/angular/cli/src/utilities/node-version_spec.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + isNodeVersionMinSupported, + isNodeVersionRunnable, + isNodeVersionSupported, +} from './node-version'; + +describe('node-version', () => { + const supportedVersions = ['22.22.3', '24.15.0', '26.0.0']; + + describe('isNodeVersionSupported', () => { + it('should accept valid LTS versions', () => { + expect(isNodeVersionSupported('22.22.3', supportedVersions)).toBeTrue(); + expect(isNodeVersionSupported('22.23.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionSupported('24.15.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionSupported('24.16.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionSupported('26.0.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionSupported('26.1.0', supportedVersions)).toBeTrue(); + }); + + it('should reject versions below the minimum for a supported major', () => { + expect(isNodeVersionSupported('22.22.2', supportedVersions)).toBeFalse(); + expect(isNodeVersionSupported('22.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionSupported('24.14.9', supportedVersions)).toBeFalse(); + expect(isNodeVersionSupported('20.18.0', supportedVersions)).toBeFalse(); + }); + + it('should reject odd-numbered major versions', () => { + expect(isNodeVersionSupported('23.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionSupported('25.0.0', supportedVersions)).toBeFalse(); + }); + + it('should reject versions higher than the maximum supported LTS version', () => { + expect(isNodeVersionSupported('27.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionSupported('28.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionSupported('29.1.2', supportedVersions)).toBeFalse(); + }); + + it('should return true when unstamped (0.0.0-ENGINES-NODE)', () => { + expect(isNodeVersionSupported()).toBeTrue(); + }); + }); + + describe('isNodeVersionRunnable', () => { + it('should allow odd-numbered interim releases between min and max major', () => { + expect(isNodeVersionRunnable('23.0.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionRunnable('25.0.0', supportedVersions)).toBeTrue(); + }); + + it('should allow versions higher than the maximum supported major', () => { + expect(isNodeVersionRunnable('27.0.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionRunnable('28.0.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionRunnable('29.1.2', supportedVersions)).toBeTrue(); + }); + + it('should return false for supported LTS versions', () => { + expect(isNodeVersionRunnable('22.22.3', supportedVersions)).toBeFalse(); + expect(isNodeVersionRunnable('24.15.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionRunnable('26.0.0', supportedVersions)).toBeFalse(); + }); + + it('should return false for versions below the minimum major', () => { + expect(isNodeVersionRunnable('18.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionRunnable('20.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionRunnable('21.0.0', supportedVersions)).toBeFalse(); + }); + + it('should return false for outdated minors of supported majors', () => { + expect(isNodeVersionRunnable('22.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionRunnable('24.10.0', supportedVersions)).toBeFalse(); + }); + }); + + describe('isNodeVersionMinSupported', () => { + it('should accept versions at or above the minimum supported version', () => { + expect(isNodeVersionMinSupported('22.22.3', supportedVersions)).toBeTrue(); + expect(isNodeVersionMinSupported('22.25.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionMinSupported('23.0.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionMinSupported('24.0.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionMinSupported('28.0.0', supportedVersions)).toBeTrue(); + }); + + it('should reject versions below the minimum supported version', () => { + expect(isNodeVersionMinSupported('22.22.2', supportedVersions)).toBeFalse(); + expect(isNodeVersionMinSupported('20.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionMinSupported('18.19.0', supportedVersions)).toBeFalse(); + }); + }); +}); diff --git a/packages/angular/cli/src/utilities/version.ts b/packages/angular/cli/src/utilities/version.ts index d16feb2d4b15..2218a39b3e78 100644 --- a/packages/angular/cli/src/utilities/version.ts +++ b/packages/angular/cli/src/utilities/version.ts @@ -6,6 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ +import { VERSION as versionString } from '#version'; + // Same structure as used in framework packages class Version { readonly major: string; @@ -20,4 +22,4 @@ class Version { } } -export const VERSION = new Version('0.0.0-PLACEHOLDER'); +export const VERSION = new Version(versionString); diff --git a/packages/angular/cli/test-esm-loader.mjs b/packages/angular/cli/test-esm-loader.mjs new file mode 100644 index 000000000000..200010ad19d4 --- /dev/null +++ b/packages/angular/cli/test-esm-loader.mjs @@ -0,0 +1,48 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile } from 'node:fs/promises'; +import { register } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +if (!globalThis.__angularCliTestEsmHookRegistered) { + globalThis.__angularCliTestEsmHookRegistered = true; + register(import.meta.url); +} + +export async function resolve(specifier, context, nextResolve) { + try { + const res = await nextResolve(specifier, context); + if (res.url.endsWith('.md')) { + return { ...res, format: 'module' }; + } + return res; + } catch (err) { + if (err && (err.code === 'ERR_MODULE_NOT_FOUND' || err.code === 'ERR_UNSUPPORTED_DIR_IMPORT')) { + for (const suffix of ['.js', '/index.js', '.json']) { + try { + return await nextResolve(specifier + suffix, context); + } catch {} + } + } + throw err; + } +} + +export async function load(url, context, nextLoad) { + if (url.endsWith('.md')) { + const content = await readFile(fileURLToPath(url), 'utf-8'); + return { + format: 'module', + shortCircuit: true, + source: `export default ${JSON.stringify(content)};`, + }; + } + + return nextLoad(url, context); +} diff --git a/packages/angular/cli/tsconfig-build.json b/packages/angular/cli/tsconfig-build.json new file mode 100644 index 000000000000..dc9987dba856 --- /dev/null +++ b/packages/angular/cli/tsconfig-build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig-build.json", + "compilerOptions": { + "module": "preserve", + "moduleResolution": "bundler", + "target": "es2022", + "types": ["node"] + } +} diff --git a/packages/angular/cli/tsconfig-test.json b/packages/angular/cli/tsconfig-test.json new file mode 100644 index 000000000000..d275aef0a373 --- /dev/null +++ b/packages/angular/cli/tsconfig-test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig-build.json", + "compilerOptions": { + "types": ["node", "jasmine"] + } +} diff --git a/packages/angular/ssr/BUILD.bazel b/packages/angular/ssr/BUILD.bazel index ec6187173fbf..73105bfb1318 100644 --- a/packages/angular/ssr/BUILD.bazel +++ b/packages/angular/ssr/BUILD.bazel @@ -22,19 +22,16 @@ ts_project( "--lib", "dom.iterable,dom,es2022", ], - data = [ - "//packages/angular/ssr/third_party/beasties:beasties_bundled", - ], source_map = True, tsconfig = "//:build-tsconfig-esm", deps = [ + ":node_modules/beasties", "//:node_modules/@angular/common", "//:node_modules/@angular/core", "//:node_modules/@angular/platform-browser", "//:node_modules/@angular/platform-server", "//:node_modules/@angular/router", "//:node_modules/tslib", - "//packages/angular/ssr/third_party/beasties:beasties_dts", ], ) @@ -42,23 +39,13 @@ ng_package( name = "npm_package", srcs = [ ":package.json", - "//packages/angular/ssr/third_party/beasties:beasties_bundled", ], externals = [ "@angular/ssr", "@angular/ssr/node", - "../../third_party/beasties", ], - extra_substitutions = { - # Needed for ssr.d.ts file - "\\./third_party/beasties": "../third_party/beasties", - # Needed for the FESM file. - "\\./(.+)/packages/angular/ssr/third_party/beasties": "../third_party/beasties/index.js", - }, nested_packages = [ "//packages/angular/ssr/schematics:pkg", - # Included directly as the generated types reference the types file in this location. - "//packages/angular/ssr/third_party/beasties:beasties_dts", ], package = "@angular/ssr", readme_md = ":README.md", diff --git a/packages/angular/ssr/node/BUILD.bazel b/packages/angular/ssr/node/BUILD.bazel index ddd737512f67..c7e8e8e2c215 100644 --- a/packages/angular/ssr/node/BUILD.bazel +++ b/packages/angular/ssr/node/BUILD.bazel @@ -21,5 +21,6 @@ ts_project( "//:node_modules/@angular/platform-server", "//:node_modules/@types/node", "//packages/angular/ssr", + "//packages/angular/ssr:node_modules/beasties", ], ) diff --git a/packages/angular/ssr/node/src/common-engine/common-engine.ts b/packages/angular/ssr/node/src/common-engine/common-engine.ts index 8db528f30f34..6af1c0f2727c 100644 --- a/packages/angular/ssr/node/src/common-engine/common-engine.ts +++ b/packages/angular/ssr/node/src/common-engine/common-engine.ts @@ -10,7 +10,7 @@ import { ApplicationRef, StaticProvider, Type } from '@angular/core'; import { BootstrapContext } from '@angular/platform-browser'; import { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server'; import * as fs from 'node:fs'; -import { dirname, join, normalize, resolve } from 'node:path'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { URL } from 'node:url'; import { validateUrl } from '../../../src/utils/validation'; import { getAllowedHostsFromEnv } from '../environment-options'; @@ -155,16 +155,18 @@ export class CommonEngine { // See: https://portswigger.net/web-security/file-path-traversal const pagePath = join(publicPath, pathname, 'index.html'); + const relativePath = relative(publicPath, pagePath); + const isOutside = + relativePath === '..' || relativePath.startsWith('../') || relativePath.startsWith('..\\'); + if (isOutside || isAbsolute(relativePath)) { + return undefined; + } + if (this.pageIsSSG.get(pagePath)) { // Serve pre-rendered page. return fs.promises.readFile(pagePath, 'utf-8'); } - if (!pagePath.startsWith(normalize(publicPath))) { - // Potential path traversal detected. - return undefined; - } - if (pagePath === resolve(documentFilePath) || !(await exists(pagePath))) { // View matches with prerender path or file does not exist. return undefined; diff --git a/packages/angular/ssr/node/src/common-engine/inline-css-processor.ts b/packages/angular/ssr/node/src/common-engine/inline-css-processor.ts index 0ec0e3eeb519..4a2198e8d05b 100644 --- a/packages/angular/ssr/node/src/common-engine/inline-css-processor.ts +++ b/packages/angular/ssr/node/src/common-engine/inline-css-processor.ts @@ -6,14 +6,41 @@ * found in the LICENSE file at https://angular.dev/license */ -import { ɵInlineCriticalCssProcessor as InlineCriticalCssProcessor } from '@angular/ssr'; +import Beasties from 'beasties'; import { readFile } from 'node:fs/promises'; export class CommonEngineInlineCriticalCssProcessor { private readonly resourceCache = new Map(); async process(html: string, outputPath: string | undefined): Promise { - const beasties = new InlineCriticalCssProcessor(async (path) => { + const processor = new Beasties({ + logger: { + // eslint-disable-next-line no-console + warn: (s: string) => console.warn(s), + // eslint-disable-next-line no-console + error: (s: string) => console.error(s), + info: () => {}, + }, + logLevel: 'warn', + path: outputPath, + publicPath: undefined, + compress: false, + pruneSource: false, + reduceInlineStyles: false, + mergeStylesheets: false, + preload: 'media-script', + nonce: (document) => { + const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]'); + const cspNonce = + nonceElement?.getAttribute('ngCspNonce') || nonceElement?.getAttribute('ngcspnonce'); + + return cspNonce; + }, + noscriptFallback: true, + inlineFonts: true, + }); + + processor.readFile = async (path: string) => { let resourceContent = this.resourceCache.get(path); if (resourceContent === undefined) { resourceContent = await readFile(path, 'utf-8'); @@ -21,8 +48,8 @@ export class CommonEngineInlineCriticalCssProcessor { } return resourceContent; - }, outputPath); + }; - return beasties.process(html); + return processor.process(html); } } diff --git a/packages/angular/ssr/node/src/request.ts b/packages/angular/ssr/node/src/request.ts index 4bd754b268cc..4f604b6a9d0e 100644 --- a/packages/angular/ssr/node/src/request.ts +++ b/packages/angular/ssr/node/src/request.ts @@ -55,9 +55,18 @@ export function createWebRequestFromNodeRequest( const { headers, method = 'GET' } = nodeRequest; const withBody = method !== 'GET' && method !== 'HEAD'; const referrer = headers.referer && URL.canParse(headers.referer) ? headers.referer : undefined; + const controller = new AbortController(); + if (nodeRequest.aborted) { + controller.abort(); + } else { + const onAbort = () => controller.abort(); + nodeRequest.once('aborted', onAbort); + nodeRequest.once('close', () => nodeRequest.off('aborted', onAbort)); + } return new Request(createRequestUrl(nodeRequest, trustProxyHeadersNormalized), { method, + signal: controller.signal, headers: createRequestHeaders(headers), body: withBody ? nodeRequest : undefined, duplex: withBody ? 'half' : undefined, diff --git a/packages/angular/ssr/node/src/response.ts b/packages/angular/ssr/node/src/response.ts index 56f091deed5f..d3b0cf403090 100644 --- a/packages/angular/ssr/node/src/response.ts +++ b/packages/angular/ssr/node/src/response.ts @@ -9,6 +9,22 @@ import type { ServerResponse } from 'node:http'; import type { Http2ServerResponse } from 'node:http2'; +/** + * Checks whether a Node.js `ServerResponse` or `Http2ServerResponse` is destroyed, closed, or ended. + * + * @param destination - The HTTP/1.1 or HTTP/2 server response to check. + * @returns `true` if the response or its underlying stream is destroyed, closed, or ended; otherwise `false`. + */ +function isResponseDestroyedOrClosed(destination: ServerResponse | Http2ServerResponse): boolean { + return ( + destination.destroyed || + destination.closed || + destination.writableEnded || + ('stream' in destination && + (!destination.stream || destination.stream.destroyed || destination.stream.closed)) + ); +} + /** * Streams a web-standard `Response` into a Node.js `ServerResponse` * or `Http2ServerResponse`. @@ -24,6 +40,10 @@ export async function writeResponseToNodeResponse( source: Response, destination: ServerResponse | Http2ServerResponse, ): Promise { + if (isResponseDestroyedOrClosed(destination)) { + return; + } + const { status, headers, body } = source; destination.statusCode = status; @@ -48,27 +68,52 @@ export async function writeResponseToNodeResponse( } if (!body) { - destination.end(); + if (!isResponseDestroyedOrClosed(destination)) { + destination.end(); + } return; } - try { - const reader = body.getReader(); - - destination.on('close', () => { - reader.cancel().catch((error) => { - // eslint-disable-next-line no-console - console.error( - `An error occurred while writing the response body for: ${destination.req.url}.`, - error, - ); - }); + let isClosed = isResponseDestroyedOrClosed(destination); + const isDestroyedOrClosed = () => isClosed || isResponseDestroyedOrClosed(destination); + + let readerCancelled = false; + const reader = body.getReader(); + const cancelReader = (error?: unknown) => { + if (readerCancelled) { + return; + } + readerCancelled = true; + isClosed = true; + destination.off('close', cancelReader); + destination.off('error', cancelReader); + reader.cancel(error).catch((err) => { + // eslint-disable-next-line no-console + console.error( + `An error occurred while writing the response body for: ${destination.req.url}.`, + err, + ); }); + }; + + destination.once('close', cancelReader); + destination.once('error', cancelReader); + try { // eslint-disable-next-line no-constant-condition while (true) { + if (isDestroyedOrClosed()) { + cancelReader(); + break; + } + const { done, value } = await reader.read(); + if (isDestroyedOrClosed()) { + cancelReader(); + break; + } + if (done) { destination.end(); break; @@ -78,10 +123,39 @@ export async function writeResponseToNodeResponse( if (canContinue === false) { // Explicitly check for `false`, as AWS may return `undefined` even though this is not valid. // See: https://github.com/CodeGenieApp/serverless-express/issues/683 - await new Promise((resolve) => destination.once('drain', resolve)); + await new Promise((resolve) => { + if (isDestroyedOrClosed()) { + resolve(); + + return; + } + + const onDrain = () => { + destination.off('close', onClose); + destination.off('error', onClose); + resolve(); + }; + + const onClose = () => { + destination.off('drain', onDrain); + destination.off('close', onClose); + destination.off('error', onClose); + cancelReader(); + resolve(); + }; + + destination.once('drain', onDrain); + destination.once('close', onClose); + destination.once('error', onClose); + }); } } } catch { - destination.end('Internal server error.'); + if (!isDestroyedOrClosed()) { + destination.end('Internal server error.'); + } + } finally { + destination.off('close', cancelReader); + destination.off('error', cancelReader); } } diff --git a/packages/angular/ssr/node/test/request_http1_spec.ts b/packages/angular/ssr/node/test/request_http1_spec.ts index 87f25f918ef7..9f11fe390ed4 100644 --- a/packages/angular/ssr/node/test/request_http1_spec.ts +++ b/packages/angular/ssr/node/test/request_http1_spec.ts @@ -189,4 +189,42 @@ describe('createWebRequestFromNodeRequest (HTTP/1.1)', () => { expect(await webRequest.text()).toBe(''); }); }); + + describe('abort handling', () => { + it('should abort the web request signal when the node request is aborted', async () => { + const nodeRequest = await extractNodeRequest(() => { + request({ + hostname: 'localhost', + port, + path: '/abort', + method: 'GET', + }).end(); + }); + + const webRequest = createWebRequestFromNodeRequest(nodeRequest); + expect(webRequest.signal.aborted).toBeFalse(); + + nodeRequest.emit('aborted'); + + expect(webRequest.signal.aborted).toBeTrue(); + }); + + it('should create an aborted web request signal when the node request is already aborted', async () => { + const nodeRequest = await extractNodeRequest(() => { + request({ + hostname: 'localhost', + port, + path: '/already-aborted', + method: 'GET', + }).end(); + }); + + Object.defineProperty(nodeRequest, 'aborted', { get: () => true, configurable: true }); + + const webRequest = createWebRequestFromNodeRequest(nodeRequest); + expect(webRequest.signal.aborted).toBeTrue(); + + delete (nodeRequest as { aborted?: boolean }).aborted; + }); + }); }); diff --git a/packages/angular/ssr/node/test/request_http2_spec.ts b/packages/angular/ssr/node/test/request_http2_spec.ts index 7079a385daaf..e652c59f6461 100644 --- a/packages/angular/ssr/node/test/request_http2_spec.ts +++ b/packages/angular/ssr/node/test/request_http2_spec.ts @@ -188,4 +188,42 @@ describe('createWebRequestFromNodeRequest (HTTP/2)', () => { expect(await webRequest.text()).toBe(''); }); }); + + describe('abort handling', () => { + it('should abort the web request signal when the node request is aborted', async () => { + const nodeRequest = await extractNodeRequest(() => { + client + .request({ + ':path': '/abort', + ':method': 'GET', + }) + .end(); + }); + + const webRequest = createWebRequestFromNodeRequest(nodeRequest); + expect(webRequest.signal.aborted).toBeFalse(); + + nodeRequest.emit('aborted'); + + expect(webRequest.signal.aborted).toBeTrue(); + }); + + it('should create an aborted web request signal when the node request is already aborted', async () => { + const nodeRequest = await extractNodeRequest(() => { + client + .request({ + ':path': '/already-aborted', + ':method': 'GET', + }) + .end(); + }); + + Object.defineProperty(nodeRequest, 'aborted', { get: () => true, configurable: true }); + + const webRequest = createWebRequestFromNodeRequest(nodeRequest); + expect(webRequest.signal.aborted).toBeTrue(); + + delete (nodeRequest as { aborted?: boolean }).aborted; + }); + }); }); diff --git a/packages/angular/ssr/node/test/response_http1_spec.ts b/packages/angular/ssr/node/test/response_http1_spec.ts index 8deae2b7e3b4..c7648f03a627 100644 --- a/packages/angular/ssr/node/test/response_http1_spec.ts +++ b/packages/angular/ssr/node/test/response_http1_spec.ts @@ -107,4 +107,48 @@ describe('writeResponseToNodeResponse (HTTP/1.1)', () => { expect(response.headers['set-cookie']).toEqual(cookieValue); }); + + it('should resolve and cancel reader when client disconnects while response is backpressured', async () => { + let writePromise!: Promise; + let readerCancelled = false; + const largeChunk = 'x'.repeat(1024 * 1024 * 4); // 4MB to ensure backpressure + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(largeChunk); + controller.enqueue(largeChunk); + }, + cancel() { + readerCancelled = true; + }, + }); + + server.once('request', (_, nodeResponse) => { + writePromise = writeResponseToNodeResponse(new Response(stream), nodeResponse); + }); + + await new Promise((resolve) => { + const { port } = server.address() as AddressInfo; + const clientRequest = requestCb( + { + host: 'localhost', + port, + }, + (response) => { + response.once('data', () => { + clientRequest.destroy(); + resolve(); + }); + }, + ); + + clientRequest.on('error', () => { + // Expected when destroying the socket + }); + + clientRequest.end(); + }); + + await writePromise; + expect(readerCancelled).toBeTrue(); + }); }); diff --git a/packages/angular/ssr/node/test/response_http2_spec.ts b/packages/angular/ssr/node/test/response_http2_spec.ts index c2de8e977561..431bc9de0c38 100644 --- a/packages/angular/ssr/node/test/response_http2_spec.ts +++ b/packages/angular/ssr/node/test/response_http2_spec.ts @@ -116,4 +116,48 @@ describe('writeResponseToNodeResponse (HTTP/2)', () => { expect(resHeaders['set-cookie']).toEqual(cookieValue); }); + + it('should resolve and cancel reader when client disconnects while response is backpressured', async () => { + let writePromise!: Promise; + let readerCancelled = false; + const largeChunk = 'x'.repeat(1024 * 1024); // 1MB to exceed HTTP/2 flow control window + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(largeChunk); + controller.enqueue(largeChunk); + }, + cancel() { + readerCancelled = true; + }, + }); + + server.once('request', (_, nodeResponse) => { + writePromise = writeResponseToNodeResponse(new Response(stream), nodeResponse); + }); + + await new Promise((resolve) => { + const { port } = server.address() as AddressInfo; + const client = connect(`http://localhost:${port}`); + const req = client.request({ + ':path': '/', + }); + + req.once('response', () => { + req.once('data', () => { + req.destroy(); + client.destroy(); + resolve(); + }); + }); + + req.on('error', () => { + // Expected when destroying/closing the stream + }); + + req.end(); + }); + + await writePromise; + expect(readerCancelled).toBeTrue(); + }); }); diff --git a/packages/angular/ssr/package.json b/packages/angular/ssr/package.json index 3ffa71cd9fe5..b48e12387746 100644 --- a/packages/angular/ssr/package.json +++ b/packages/angular/ssr/package.json @@ -22,6 +22,7 @@ "save": "dependencies" }, "dependencies": { + "beasties": "0.5.4", "tslib": "^2.3.0" }, "peerDependencies": { @@ -37,14 +38,13 @@ }, "devDependencies": { "@angular-devkit/schematics": "workspace:*", - "@angular/common": "22.1.0-rc.0", - "@angular/compiler": "22.1.0-rc.0", - "@angular/core": "22.1.0-rc.0", - "@angular/platform-browser": "22.1.0-rc.0", - "@angular/platform-server": "22.1.0-rc.0", - "@angular/router": "22.1.0-rc.0", - "@schematics/angular": "workspace:*", - "beasties": "0.4.3" + "@angular/common": "22.2.0-rc.0", + "@angular/compiler": "22.2.0-rc.0", + "@angular/core": "22.2.0-rc.0", + "@angular/platform-browser": "22.2.0-rc.0", + "@angular/platform-server": "22.2.0-rc.0", + "@angular/router": "22.2.0-rc.0", + "@schematics/angular": "workspace:*" }, "sideEffects": false, "schematics": "./schematics/collection.json" diff --git a/packages/angular/ssr/private_export.ts b/packages/angular/ssr/private_export.ts index 273633b484fd..2064c530f74e 100644 --- a/packages/angular/ssr/private_export.ts +++ b/packages/angular/ssr/private_export.ts @@ -19,5 +19,3 @@ export { setAngularAppManifest as ɵsetAngularAppManifest, setAngularAppEngineManifest as ɵsetAngularAppEngineManifest, } from './src/manifest'; - -export { InlineCriticalCssProcessor as ɵInlineCriticalCssProcessor } from './src/utils/inline-critical-css'; diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index a9ca7dd05fe3..6c0202a8f9b3 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -14,15 +14,13 @@ import { StaticProvider, ɵresetCompiledComponents, } from '@angular/core'; +import { createProcessor } from 'beasties/runtime'; import { ServerAssets } from './assets'; import { Hooks } from './hooks'; import { getAngularAppManifest } from './manifest'; import { RenderMode } from './routes/route-config'; import { RouteTreeNodeMetadata } from './routes/route-tree'; import { ServerRouter } from './routes/router'; -import { sha256 } from './utils/crypto'; -import { InlineCriticalCssProcessor } from './utils/inline-critical-css'; -import { LRUCache } from './utils/lru-cache'; import { AngularBootstrap, renderAngular } from './utils/ng'; import { promiseWithAbort } from './utils/promise'; import { createRedirectResponse } from './utils/redirect'; @@ -39,12 +37,6 @@ const WELL_KNOWN_NON_ANGULAR_URLS: ReadonlySet = new Set([ '/.well-known/appspecific/com.chrome.devtools.json', ]); -/** - * Maximum number of critical CSS entries the cache can store. - * This value determines the capacity of the LRU (Least Recently Used) cache, which stores critical CSS for pages. - */ -const MAX_INLINE_CSS_CACHE_ENTRIES = 50; - /** * A mapping of `RenderMode` enum values to corresponding string representations. * @@ -114,14 +106,6 @@ export class AngularServerApp { constructor(private readonly options: Readonly = {}) { this.allowStaticRouteRender = this.options.allowStaticRouteRender ?? false; this.hooks = options.hooks ?? new Hooks(); - - if (this.manifest.inlineCriticalCss) { - this.inlineCriticalCssProcessor = new InlineCriticalCssProcessor((path: string) => { - const fileName = path.split('/').pop() ?? path; - - return this.assets.getServerAsset(fileName).text(); - }); - } } /** @@ -142,7 +126,7 @@ export class AngularServerApp { /** * The `inlineCriticalCssProcessor` is responsible for handling critical CSS inlining. */ - private inlineCriticalCssProcessor: InlineCriticalCssProcessor | undefined; + private inlineCriticalCssProcessor?: (html: string) => string; /** * The bootstrap mechanism for the server application. @@ -150,20 +134,9 @@ export class AngularServerApp { private boostrap: AngularBootstrap | undefined; /** - * Decorder used to convert a string to a Uint8Array. - */ - private readonly textDecoder = new TextEncoder(); - - /** - * A cache that stores critical CSS to avoid re-processing for every request, improving performance. - * This cache uses a Least Recently Used (LRU) eviction policy. - * - * @see {@link MAX_INLINE_CSS_CACHE_ENTRIES} for the maximum number of entries this cache can hold. + * Encoder used to convert a string to a Uint8Array. */ - private readonly criticalCssLRUCache = new LRUCache< - string, - { shaOfContentPreInlinedCss: string; contentWithCriticialCSS: Uint8Array } - >(MAX_INLINE_CSS_CACHE_ENTRIES); + private readonly textEncoder = new TextEncoder(); /** * Handles an incoming HTTP request by serving prerendered content, performing server-side rendering, @@ -358,7 +331,7 @@ export class AngularServerApp { if (renderMode === RenderMode.Prerender) { const renderedHtml = await result.content(); - const finalHtml = await this.inlineCriticalCss(renderedHtml, url); + const finalHtml = this.inlineCriticalCss(renderedHtml); return new Response(finalHtml, responseInit); } @@ -366,10 +339,18 @@ export class AngularServerApp { // Use a stream to send the response before finishing rendering and inling critical CSS, improving performance via header flushing. const stream = new ReadableStream({ start: async (controller) => { - const renderedHtml = await result.content(); - const finalHtml = await this.inlineCriticalCssWithCache(renderedHtml, url); - controller.enqueue(finalHtml); - controller.close(); + try { + let renderedHtml = await result.content(); + renderedHtml = this.inlineCriticalCss(renderedHtml); + controller.enqueue(this.textEncoder.encode(renderedHtml)); + controller.close(); + } catch (error) { + result.destroy(); + controller.error(error); + } + }, + cancel: () => { + result.destroy(); }, }); @@ -380,61 +361,37 @@ export class AngularServerApp { * Inlines critical CSS into the given HTML content. * * @param html The HTML content to process. - * @param url The URL associated with the request, for logging purposes. - * @returns A promise that resolves to the HTML with inlined critical CSS. + * @returns The HTML with inlined critical CSS. */ - private async inlineCriticalCss(html: string, url: URL): Promise { - const { inlineCriticalCssProcessor } = this; - - if (!inlineCriticalCssProcessor) { + private inlineCriticalCss(html: string): string { + const { criticalCssPlans, nonce } = this.manifest; + if (!criticalCssPlans?.length) { return html; } try { - return await inlineCriticalCssProcessor.process(html); + this.inlineCriticalCssProcessor ??= createProcessor([...criticalCssPlans], { + preload: 'media-script', + nonce, + preloadFonts: true, + inlineFonts: true, + noscriptFallback: true, + cache: true, + logger: { + // eslint-disable-next-line no-console + warn: console.warn, + }, + }).process; + + return this.inlineCriticalCssProcessor(html); } catch (error) { // eslint-disable-next-line no-console - console.error(`An error occurred while inlining critical CSS for: ${url}.`, error); + console.error('An error occurred while inlining critical CSS.', error); return html; } } - /** - * Inlines critical CSS into the given HTML content. - * This method uses a cache to avoid reprocessing the same HTML content multiple times. - * - * @param html The HTML content to process. - * @param url The URL associated with the request, for logging purposes. - * @returns A promise that resolves to the HTML with inlined critical CSS. - */ - private async inlineCriticalCssWithCache( - html: string, - url: URL, - ): Promise> { - const { inlineCriticalCssProcessor, criticalCssLRUCache, textDecoder } = this; - - if (!inlineCriticalCssProcessor) { - return textDecoder.encode(html); - } - - const cacheKey = url.toString(); - const cached = criticalCssLRUCache.get(cacheKey); - const shaOfContentPreInlinedCss = await sha256(html); - if (cached?.shaOfContentPreInlinedCss === shaOfContentPreInlinedCss) { - return cached.contentWithCriticialCSS; - } - - const processedHtml = await this.inlineCriticalCss(html, url); - const finalHtml = textDecoder.encode(processedHtml); - criticalCssLRUCache.put(cacheKey, { - shaOfContentPreInlinedCss, - contentWithCriticialCSS: finalHtml, - }); - - return finalHtml; - } - /** * Constructs the asset path on the server based on the provided HTTP request. * @@ -447,12 +404,19 @@ export class AngularServerApp { */ private buildServerAssetPathFromRequest(request: Request): string { let { pathname: assetPath } = new URL(request.url); + try { + assetPath = decodeURIComponent(assetPath); + } catch { + // In case of malformed URI component, keep assetPath as is. + } + if (!assetPath.endsWith('/index.html')) { // Append "index.html" to build the default asset path. assetPath = joinUrlParts(assetPath, 'index.html'); } const { baseHref } = this.manifest; + // Check if the asset path starts with the base href and the base href is not (`/` or ``). if (baseHref.length > 1 && assetPath.startsWith(baseHref)) { // Remove the base href from the start of the asset path to align with server-asset expectations. diff --git a/packages/angular/ssr/src/manifest.ts b/packages/angular/ssr/src/manifest.ts index 21ded49b3e10..f09872658ff7 100644 --- a/packages/angular/ssr/src/manifest.ts +++ b/packages/angular/ssr/src/manifest.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { CompactPlan } from 'beasties/runtime'; import type { SerializableRouteTreeNode } from './routes/route-tree'; import { AngularBootstrap } from './utils/ng'; @@ -106,10 +107,14 @@ export interface AngularAppManifest { readonly bootstrap: () => Promise; /** - * Indicates whether critical CSS should be inlined into the HTML. - * If set to `true`, critical CSS will be inlined for faster page rendering. + * Pre-compiled critical CSS plans generated at build time. */ - readonly inlineCriticalCss?: boolean; + readonly criticalCssPlans?: readonly CompactPlan[]; + + /** + * Content Security Policy (CSP) nonce to be used for inlined critical CSS. + */ + readonly nonce?: string; /** * The route tree representation for the routing configuration of the application. diff --git a/packages/angular/ssr/src/utils/crypto.ts b/packages/angular/ssr/src/utils/crypto.ts deleted file mode 100644 index 0a53cceb2b11..000000000000 --- a/packages/angular/ssr/src/utils/crypto.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -/** - * Generates a SHA-256 hash of the provided string. - * - * @param data - The input string to be hashed. - * @returns A promise that resolves to the SHA-256 hash of the input, - * represented as a hexadecimal string. - */ -export async function sha256(data: string): Promise { - const encodedData = new TextEncoder().encode(data); - const hashBuffer = await crypto.subtle.digest('SHA-256', encodedData); - const hashParts: string[] = []; - - for (const h of new Uint8Array(hashBuffer)) { - hashParts.push(h.toString(16).padStart(2, '0')); - } - - return hashParts.join(''); -} diff --git a/packages/angular/ssr/src/utils/inline-critical-css.ts b/packages/angular/ssr/src/utils/inline-critical-css.ts deleted file mode 100644 index 5a5eb011c20a..000000000000 --- a/packages/angular/ssr/src/utils/inline-critical-css.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -import Beasties from '../../third_party/beasties'; - -/** - * Pattern used to extract the media query set by Beasties in an `onload` handler. - */ -const MEDIA_SET_HANDLER_PATTERN = /^this\.media=["'](.*)["'];?$/; - -/** - * Name of the attribute used to save the Beasties media query so it can be re-assigned on load. - */ -const CSP_MEDIA_ATTR = 'ngCspMedia'; - -/** - * Script that dynamically updates the `media` attribute of `` tags based on a custom attribute (`CSP_MEDIA_ATTR`). - * - * NOTE: - * We do not use `document.querySelectorAll('link').forEach((s) => s.addEventListener('load', ...)` - * because load events are not always triggered reliably on Chrome. - * See: https://github.com/angular/angular-cli/issues/26932 and https://crbug.com/1521256 - * - * The script: - * - Ensures the event target is a `` tag with the `CSP_MEDIA_ATTR` attribute. - * - Updates the `media` attribute with the value of `CSP_MEDIA_ATTR` and then removes the attribute. - * - Removes the event listener when all relevant `` tags have been processed. - * - Uses event capturing (the `true` parameter) since load events do not bubble up the DOM. - */ -const LINK_LOAD_SCRIPT_CONTENT = /* @__PURE__ */ (() => `(() => { - const CSP_MEDIA_ATTR = '${CSP_MEDIA_ATTR}'; - const documentElement = document.documentElement; - - // Listener for load events on link tags. - const listener = (e) => { - const target = e.target; - if ( - !target || - target.tagName !== 'LINK' || - !target.hasAttribute(CSP_MEDIA_ATTR) - ) { - return; - } - - target.media = target.getAttribute(CSP_MEDIA_ATTR); - target.removeAttribute(CSP_MEDIA_ATTR); - - if (!document.head.querySelector(\`link[\${CSP_MEDIA_ATTR}]\`)) { - documentElement.removeEventListener('load', listener); - } - }; - - documentElement.addEventListener('load', listener, true); -})();`)(); - -/** Partial representation of an `HTMLElement`. */ -interface PartialHTMLElement { - getAttribute(name: string): string | null; - setAttribute(name: string, value: string): void; - hasAttribute(name: string): boolean; - removeAttribute(name: string): void; - appendChild(child: PartialHTMLElement): void; - insertBefore(newNode: PartialHTMLElement, referenceNode?: PartialHTMLElement): void; - remove(): void; - name: string; - textContent: string; - tagName: string | null; - children: PartialHTMLElement[]; - next: PartialHTMLElement | null; - prev: PartialHTMLElement | null; -} - -/** Partial representation of an HTML `Document`. */ -interface PartialDocument { - head: PartialHTMLElement; - createElement(tagName: string): PartialHTMLElement; - querySelector(selector: string): PartialHTMLElement | null; -} - -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ - -// We use Typescript declaration merging because `embedLinkedStylesheet` it's not declared in -// the `Beasties` types which means that we can't call the `super` implementation. -interface BeastiesBase { - embedLinkedStylesheet(link: PartialHTMLElement, document: PartialDocument): Promise; -} -class BeastiesBase extends Beasties {} -/* eslint-enable @typescript-eslint/no-unsafe-declaration-merging */ - -export class InlineCriticalCssProcessor extends BeastiesBase { - private addedCspScriptsDocuments = new WeakSet(); - private documentNonces = new WeakMap(); - - constructor( - public override readFile: (path: string) => Promise, - readonly outputPath?: string, - ) { - super({ - logger: { - // eslint-disable-next-line no-console - warn: (s: string) => console.warn(s), - // eslint-disable-next-line no-console - error: (s: string) => console.error(s), - info: () => {}, - }, - logLevel: 'warn', - path: outputPath, - publicPath: undefined, - compress: false, - pruneSource: false, - reduceInlineStyles: false, - mergeStylesheets: false, - // Note: if `preload` changes to anything other than `media`, the logic in - // `embedLinkedStylesheet` will have to be updated. - preload: 'media', - noscriptFallback: true, - inlineFonts: true, - }); - } - - /** - * Override of the Beasties `embedLinkedStylesheet` method - * that makes it work with Angular's CSP APIs. - */ - override async embedLinkedStylesheet( - link: PartialHTMLElement, - document: PartialDocument, - ): Promise { - if (link.getAttribute('media') === 'print' && link.next?.name === 'noscript') { - // Workaround for https://github.com/GoogleChromeLabs/critters/issues/64 - // NB: this is only needed for the webpack based builders. - const media = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); - if (media) { - link.removeAttribute('onload'); - link.setAttribute('media', media[1]); - link?.next?.remove(); - } - } - - const returnValue = await super.embedLinkedStylesheet(link, document); - const cspNonce = this.findCspNonce(document); - - if (cspNonce) { - const beastiesMedia = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); - - if (beastiesMedia) { - // If there's a Beasties-generated `onload` handler and the file has an Angular CSP nonce, - // we have to remove the handler, because it's incompatible with CSP. We save the value - // in a different attribute and we generate a script tag with the nonce that uses - // `addEventListener` to apply the media query instead. - link.removeAttribute('onload'); - link.setAttribute(CSP_MEDIA_ATTR, beastiesMedia[1]); - this.conditionallyInsertCspLoadingScript(document, cspNonce, link); - } - - // Ideally we would hook in at the time Beasties inserts the `style` tags, but there isn't - // a way of doing that at the moment so we fall back to doing it any time a `link` tag is - // inserted. We mitigate it by only iterating the direct children of the `` which - // should be pretty shallow. - document.head.children.forEach((child) => { - if (child.tagName === 'style' && !child.hasAttribute('nonce')) { - child.setAttribute('nonce', cspNonce); - } - }); - } - - return returnValue; - } - - /** - * Finds the CSP nonce for a specific document. - */ - private findCspNonce(document: PartialDocument): string | null { - if (this.documentNonces.has(document)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this.documentNonces.get(document)!; - } - - // HTML attribute are case-insensitive, but the parser used by Beasties is case-sensitive. - const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]'); - const cspNonce = - nonceElement?.getAttribute('ngCspNonce') || nonceElement?.getAttribute('ngcspnonce') || null; - - this.documentNonces.set(document, cspNonce); - - return cspNonce; - } - - /** - * Inserts the `script` tag that swaps the critical CSS at runtime, - * if one hasn't been inserted into the document already. - */ - private conditionallyInsertCspLoadingScript( - document: PartialDocument, - nonce: string, - link: PartialHTMLElement, - ): void { - if (this.addedCspScriptsDocuments.has(document)) { - return; - } - - if (document.head.textContent.includes(LINK_LOAD_SCRIPT_CONTENT)) { - // Script was already added during the build. - this.addedCspScriptsDocuments.add(document); - - return; - } - - const script = document.createElement('script'); - script.setAttribute('nonce', nonce); - script.textContent = LINK_LOAD_SCRIPT_CONTENT; - // Prepend the script to the head since it needs to - // run as early as possible, before the `link` tags. - document.head.insertBefore(script, link); - this.addedCspScriptsDocuments.add(document); - } -} diff --git a/packages/angular/ssr/src/utils/lru-cache.ts b/packages/angular/ssr/src/utils/lru-cache.ts deleted file mode 100644 index 89824e5e1226..000000000000 --- a/packages/angular/ssr/src/utils/lru-cache.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -/** - * Represents a node in the doubly linked list. - */ -interface Node { - key: Key; - value: Value; - prev: Node | undefined; - next: Node | undefined; -} - -/** - * A Least Recently Used (LRU) cache implementation. - * - * This cache stores a fixed number of key-value pairs, and when the cache exceeds its capacity, - * the least recently accessed items are evicted. - * - * @template Key - The type of the cache keys. - * @template Value - The type of the cache values. - */ -export class LRUCache { - /** - * The maximum number of items the cache can hold. - */ - capacity: number; - - /** - * Internal storage for the cache, mapping keys to their associated nodes in the linked list. - */ - private readonly cache = new Map>(); - - /** - * Head of the doubly linked list, representing the most recently used item. - */ - private head: Node | undefined; - - /** - * Tail of the doubly linked list, representing the least recently used item. - */ - private tail: Node | undefined; - - /** - * Creates a new LRUCache instance. - * @param capacity The maximum number of items the cache can hold. - */ - constructor(capacity: number) { - this.capacity = capacity; - } - - /** - * Gets the value associated with the given key. - * @param key The key to retrieve the value for. - * @returns The value associated with the key, or undefined if the key is not found. - */ - get(key: Key): Value | undefined { - const node = this.cache.get(key); - if (node) { - this.moveToHead(node); - - return node.value; - } - - return undefined; - } - - /** - * Puts a key-value pair into the cache. - * If the key already exists, the value is updated. - * If the cache is full, the least recently used item is evicted. - * @param key The key to insert or update. - * @param value The value to associate with the key. - */ - put(key: Key, value: Value): void { - const cachedNode = this.cache.get(key); - if (cachedNode) { - // Update existing node - cachedNode.value = value; - this.moveToHead(cachedNode); - - return; - } - - // Create a new node - const newNode: Node = { key, value, prev: undefined, next: undefined }; - this.cache.set(key, newNode); - this.addToHead(newNode); - - if (this.cache.size > this.capacity) { - // Evict the LRU item - const tail = this.removeTail(); - if (tail) { - this.cache.delete(tail.key); - } - } - } - - /** - * Adds a node to the head of the linked list. - * @param node The node to add. - */ - private addToHead(node: Node): void { - node.next = this.head; - node.prev = undefined; - - if (this.head) { - this.head.prev = node; - } - - this.head = node; - - if (!this.tail) { - this.tail = node; - } - } - - /** - * Removes a node from the linked list. - * @param node The node to remove. - */ - private removeNode(node: Node): void { - if (node.prev) { - node.prev.next = node.next; - } else { - this.head = node.next; - } - - if (node.next) { - node.next.prev = node.prev; - } else { - this.tail = node.prev; - } - } - - /** - * Moves a node to the head of the linked list. - * @param node The node to move. - */ - private moveToHead(node: Node): void { - this.removeNode(node); - this.addToHead(node); - } - - /** - * Removes the tail node from the linked list. - * @returns The removed tail node, or undefined if the list is empty. - */ - private removeTail(): Node | undefined { - const node = this.tail; - if (node) { - this.removeNode(node); - } - - return node; - } -} diff --git a/packages/angular/ssr/src/utils/ng.ts b/packages/angular/ssr/src/utils/ng.ts index 55d054bd2387..4ae3a7f4ed0f 100644 --- a/packages/angular/ssr/src/utils/ng.ts +++ b/packages/angular/ssr/src/utils/ng.ts @@ -34,8 +34,7 @@ import { addTrailingSlash, joinUrlParts, stripIndexHtmlFromURL, stripTrailingSla * - A function that returns a `Promise`, which resolves with the root application reference. */ export type AngularBootstrap = - | Type - | ((context: BootstrapContext) => Promise); + Type | ((context: BootstrapContext) => Promise); /** * Renders an Angular application or module to an HTML string. @@ -60,7 +59,12 @@ export async function renderAngular( serverContext: string, ): Promise< | { hasNavigationError: true } - | { hasNavigationError: boolean; redirectTo?: string; content: () => Promise } + | { + hasNavigationError: boolean; + redirectTo?: string; + content: () => Promise; + destroy: () => void; + } > { // A request to `http://www.example.com/page/index.html` will render the Angular route corresponding to `http://www.example.com/page`. const urlToRender = stripIndexHtmlFromURL(url); @@ -134,6 +138,7 @@ export async function renderAngular( } return { + destroy: () => void asyncDestroyPlatform(platformRef), hasNavigationError, redirectTo, content: () => @@ -177,6 +182,10 @@ export function isNgModule(value: AngularBootstrap): value is Type { * @param platformRef - The platform reference to be destroyed. */ function asyncDestroyPlatform(platformRef: PlatformRef): Promise { + if (platformRef.destroyed) { + return Promise.resolve(); + } + return new Promise((resolve) => { setTimeout(() => { if (!platformRef.destroyed) { diff --git a/packages/angular/ssr/src/utils/validation.ts b/packages/angular/ssr/src/utils/validation.ts index 714042b52f76..73e6c712d80b 100644 --- a/packages/angular/ssr/src/utils/validation.ts +++ b/packages/angular/ssr/src/utils/validation.ts @@ -88,7 +88,7 @@ export function validateUrl(url: URL, allowedHosts: ReadonlySet): void { /** * Sanitizes the proxy headers of a request by removing unallowed `X-Forwarded-*` headers. - * If no headers need to be removed, the original request is returned without cloning. + * If no headers need to be removed, the original request is returned unchanged. * * @param request - The incoming `Request` object to sanitize. * @param trustProxyHeaders - A set of allowed proxy headers. @@ -117,8 +117,7 @@ export function sanitizeRequestHeaders( } return headersDeleted - ? new Request(request.clone(), { - signal: request.signal, + ? new Request(request, { headers, }) : request; diff --git a/packages/angular/ssr/test/BUILD.bazel b/packages/angular/ssr/test/BUILD.bazel index 98ee6bbc4d4d..7f4825984c0b 100644 --- a/packages/angular/ssr/test/BUILD.bazel +++ b/packages/angular/ssr/test/BUILD.bazel @@ -16,6 +16,7 @@ ts_project( "//:node_modules/@angular/router", "//:node_modules/@types/node", "//packages/angular/ssr", + "//packages/angular/ssr:node_modules/beasties", ], ) @@ -23,7 +24,6 @@ spec_bundle( name = "esm_tests_bundled", deps = [ ":unit_test_lib", - "//packages/angular/ssr/third_party/beasties:beasties_bundled", ], ) diff --git a/packages/angular/ssr/test/app-engine_spec.ts b/packages/angular/ssr/test/app-engine_spec.ts index c4313be8a48c..eae063eec700 100644 --- a/packages/angular/ssr/test/app-engine_spec.ts +++ b/packages/angular/ssr/test/app-engine_spec.ts @@ -54,11 +54,13 @@ function createEntryPoint(locale: string) { setAngularAppTestingManifest( [ { path: 'ssg', component: SSGComponent }, + { path: 'ssg-non-ascii/دليل', component: SSGComponent }, { path: 'ssr', component: SSRComponent }, { path: '', component: HomeComponent }, ], [ { path: 'ssg', renderMode: RenderMode.Prerender }, + { path: 'ssg-non-ascii/دليل', renderMode: RenderMode.Prerender }, { path: '**', renderMode: RenderMode.Server }, ], '/' + locale, @@ -77,6 +79,20 @@ function createEntryPoint(locale: string) { `, }, + 'ssg-non-ascii/دليل/index.html': { + size: 35, + hash: 'a1b2c3d4e5f6', + text: async () => ` + + SSG non-ascii page + + + + SSG non-ascii works ${locale.toUpperCase()} + + + `, + }, }, locale, ); @@ -147,6 +163,15 @@ describe('AngularAppEngine', () => { expect(await response?.text()).toContain('SSG works IT'); }); + it('should return a served prerendered page for non-ASCII routes with correct locale', async () => { + const request = new Request( + 'https://example.com/it/ssg-non-ascii/%D8%AF%D9%84%D9%8A%D9%84', + ); + const response = await appEngine.handle(request); + expect(await response?.text()).toContain('SSG non-ascii works IT'); + expect(response?.headers?.get('Content-Language')).toBe('it'); + }); + it('should return null for requests to unknown pages in a locale', async () => { const request = new Request('https://example.com/it/unknown/page'); const response = await appEngine.handle(request); diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts index de4b1bcb988e..56f4c6fd7b24 100644 --- a/packages/angular/ssr/test/app_spec.ts +++ b/packages/angular/ssr/test/app_spec.ts @@ -14,6 +14,7 @@ import '@angular/compiler'; import { APP_BASE_HREF } from '@angular/common'; import { Component, PlatformRef, REQUEST, RESPONSE_INIT, inject } from '@angular/core'; import { ActivatedRoute, CanActivateFn, Router } from '@angular/router'; +import { setTimeout } from 'node:timers/promises'; import { AngularServerApp } from '../src/app'; import { RenderMode } from '../src/routes/route-config'; import { setAngularAppTestingManifest } from './testing-utils'; @@ -21,58 +22,59 @@ import { setAngularAppTestingManifest } from './testing-utils'; describe('AngularServerApp', () => { let app: AngularServerApp; - beforeAll(() => { - @Component({ - selector: 'app-home', - template: `Home works`, - }) - class HomeComponent { - constructor() { - if (inject(ActivatedRoute).snapshot.data['destroyApp']) { - inject(PlatformRef).destroy(); - } + @Component({ + selector: 'app-home', + template: `Home works`, + }) + class HomeComponent { + constructor() { + if (inject(ActivatedRoute).snapshot.data['destroyApp']) { + inject(PlatformRef).destroy(); } } - - @Component({ - selector: 'app-redirect', - }) - class RedirectComponent { - constructor() { - const responseInit = inject(RESPONSE_INIT); - if (responseInit) { - responseInit.status = 308; - const headers = responseInit.headers; - if (headers) { - (headers as Headers).set('X-Redirect-Header', 'custom-value'); - } + } + + @Component({ + selector: 'app-redirect', + }) + class RedirectComponent { + constructor() { + const responseInit = inject(RESPONSE_INIT); + if (responseInit) { + responseInit.status = 308; + const headers = responseInit.headers; + if (headers) { + (headers as Headers).set('X-Redirect-Header', 'custom-value'); } - - void inject(Router).navigate([], { - queryParams: { filter: 'test' }, - }); } - } - const queryParamAdderGuard: CanActivateFn = (_route, state) => { - const urlTree = inject(Router).parseUrl(state.url); + void inject(Router).navigate([], { + queryParams: { filter: 'test' }, + }); + } + } - if (urlTree.queryParamMap.has('filter')) { - return true; - } + const queryParamAdderGuard: CanActivateFn = (_route, state) => { + const urlTree = inject(Router).parseUrl(state.url); - urlTree.queryParams = { - filter: 'test', - }; + if (urlTree.queryParamMap.has('filter')) { + return true; + } - return urlTree; + urlTree.queryParams = { + filter: 'test', }; + return urlTree; + }; + + function setupManifest(): void { setAngularAppTestingManifest( [ { path: 'home', component: HomeComponent }, { path: 'home-csr', component: HomeComponent }, { path: 'home-ssg', component: HomeComponent }, + { path: 'home-ssg-non-ascii/دليل', component: HomeComponent }, { path: 'page-with-headers', component: HomeComponent }, { path: 'page-with-status', component: HomeComponent }, { path: 'page-destroy-app', component: HomeComponent, data: { destroyApp: true } }, @@ -104,6 +106,10 @@ describe('AngularServerApp', () => { 'X-Some-Header': 'value', }, }, + { + path: 'home-ssg-non-ascii/دليل', + renderMode: RenderMode.Prerender, + }, { path: 'page-with-status', renderMode: RenderMode.Server, @@ -139,6 +145,21 @@ describe('AngularServerApp', () => { size: 28, hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde', }, + 'home-ssg-non-ascii/دليل/index.html': { + text: async () => + ` + + SSG non-ascii page + + + + Home SSG non-ascii works + + + `, + size: 38, + hash: 'a1b2c3d4e5f6', + }, }, undefined, undefined, @@ -151,6 +172,10 @@ describe('AngularServerApp', () => { ); app = new AngularServerApp(); + } + + beforeAll(() => { + setupManifest(); }); describe('handle', () => { @@ -281,6 +306,77 @@ describe('AngularServerApp', () => { expect(await response?.text()).toContain('Home SSG works'); }); + it('should correctly serve prerendered page with non-ASCII path', async () => { + const response = await app.handle( + new Request('http://localhost/home-ssg-non-ascii/%D8%AF%D9%84%D9%8A%D9%84'), + ); + expect(await response?.text()).toContain('Home SSG non-ascii works'); + }); + + it(`should correctly serve prerendered page with non-ASCII path when the URL ends with 'index.html'`, async () => { + const response = await app.handle( + new Request('http://localhost/home-ssg-non-ascii/%D8%AF%D9%84%D9%8A%D9%84/index.html'), + ); + expect(await response?.text()).toContain('Home SSG non-ascii works'); + }); + + it('should correctly serve prerendered page when requested with decoded non-ASCII characters', async () => { + const response = await app.handle(new Request('http://localhost/home-ssg-non-ascii/دليل')); + expect(await response?.text()).toContain('Home SSG non-ascii works'); + }); + + it('should correctly serve prerendered page with non-ASCII path when baseHref is configured', async () => { + setAngularAppTestingManifest( + [{ path: 'home-ssg-non-ascii/دليل', component: HomeComponent }], + [ + { + path: 'home-ssg-non-ascii/دليل', + renderMode: RenderMode.Prerender, + }, + ], + '/ar/', + { + 'home-ssg-non-ascii/دليل/index.html': { + text: async () => 'SSG with baseHref works', + size: 47, + hash: '123456', + }, + }, + ); + + const customApp = new AngularServerApp(); + const response = await customApp.handle( + new Request('http://localhost/ar/home-ssg-non-ascii/%D8%AF%D9%84%D9%8A%D9%84'), + ); + expect(await response?.text()).toContain('SSG with baseHref works'); + }); + + it('should correctly serve prerendered page when baseHref contains non-ASCII characters', async () => { + setAngularAppTestingManifest( + [{ path: 'page', component: HomeComponent }], + [ + { + path: 'page', + renderMode: RenderMode.Prerender, + }, + ], + '/دليل/', + { + 'page/index.html': { + text: async () => 'SSG with non-ASCII baseHref works', + size: 57, + hash: 'abcdef', + }, + }, + ); + + const customApp = new AngularServerApp(); + const response = await customApp.handle( + new Request('http://localhost/%D8%AF%D9%84%D9%8A%D9%84/page'), + ); + expect(await response?.text()).toContain('SSG with non-ASCII baseHref works'); + }); + it('should return configured headers for pages with specific header settings', async () => { const response = await app.handle(new Request('http://localhost/home-ssg')); const headers = response?.headers.entries() ?? []; @@ -365,6 +461,16 @@ describe('AngularServerApp', () => { expect(await response?.text()).toContain('Home works'); }); + it('should destroy the platform when the response stream is cancelled', async () => { + const destroySpy = spyOn(PlatformRef.prototype, 'destroy').and.callThrough(); + const response = await app.handle(new Request('http://localhost/home')); + expect(response?.body).toBeInstanceOf(ReadableStream); + await response?.body?.cancel(); + // Wait for the macrotask queue to clear since destroy is called asynchronously + await setTimeout(0); + expect(destroySpy).toHaveBeenCalled(); + }); + describe('APP_BASE_HREF / X-Forwarded-Prefix', () => { const headers = new Headers({ 'X-Forwarded-Prefix': '/base/' }); @@ -394,6 +500,68 @@ describe('AngularServerApp', () => { expect(response?.status).toBe(302); }); }); + + describe('Critical CSS inlining', () => { + afterEach(() => { + setupManifest(); + }); + + it('should inline critical CSS when inlineCriticalCss is provided', async () => { + setAngularAppTestingManifest( + [{ path: 'home', component: HomeComponent }], + [{ path: 'home', renderMode: RenderMode.Server }], + '/', + { + 'index.server.html': { + size: 100, + hash: '123', + text: async () => + ``, + }, + }, + undefined, + undefined, + undefined, + { 'styles.css': 'app-home{color:blue}' }, + ); + + const customApp = new AngularServerApp(); + const response = await customApp.handle(new Request('http://localhost/home')); + const html = await response?.text(); + + expect(html).toContain(''); + expect(html).toContain('data-beasties-media="all"'); + expect(html).toContain("document.querySelectorAll('link[data-beasties-media]')"); + }); + + it('should apply CSP nonce to inlined style and script tags when nonce is configured', async () => { + setAngularAppTestingManifest( + [{ path: 'home', component: HomeComponent }], + [{ path: 'home', renderMode: RenderMode.Server }], + '/', + { + 'index.server.html': { + size: 100, + hash: '123', + text: async () => + ``, + }, + }, + undefined, + undefined, + undefined, + { 'styles.css': 'app-home{color:blue}' }, + '{% nonce %}', + ); + + const customApp = new AngularServerApp(); + const response = await customApp.handle(new Request('http://localhost/home')); + const html = await response?.text(); + + expect(html).toContain(''); + expect(html).toContain('