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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Dotnet.Watch/HotReloadClient/Logging/LogEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,7 @@ public static void Log<TArg1, TArg2, TArg3>(this ILogger logger, LogEvent<(TArg1
public static readonly LogEvent<string> ManifestFileNotFound = Create<string>(LogLevel.Debug, "Manifest file '{0}' not found.");
public static readonly LogEvent<string> ProjectSpecifiesCapabilities = Create<string>(LogLevel.Debug, "Project specifies capabilities: {0}.");
public static readonly LogEvent<(Version, string)> UsingCapabilitiesBasedOnTargetFrameworkVersion = Create<(Version, string)>(LogLevel.Debug, "Using capabilities based on project target framework version: '{0}': {1}.");
public static readonly LogEvent<None> StaticWebAssetManifestNotFound = Create(LogLevel.Warning, "Static web asset manifest not found.");
public static readonly LogEvent<string> ScopedCssBundleFileNotFound = Create<string>(LogLevel.Warning, "Scoped CSS bundle file '{0}' not found.");
}

Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.DotNet.Watch;
#nullable enable

namespace Microsoft.DotNet.HotReload;

internal readonly record struct ProjectInstanceId(string ProjectPath, string TargetFramework);
28 changes: 22 additions & 6 deletions src/Dotnet.Watch/HotReloadClient/Web/StaticWebAsset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ namespace Microsoft.DotNet.HotReload;

internal readonly struct StaticWebAsset(string filePath, string relativeUrl, string assemblyName, bool isApplicationProject)
{
private static readonly StringComparison OSSpecificPathComparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;

public string FilePath => filePath;
public string RelativeUrl => relativeUrl;
public string AssemblyName => assemblyName;
Comment thread
tmat marked this conversation as resolved.
Expand All @@ -19,8 +21,10 @@ internal readonly struct StaticWebAsset(string filePath, string relativeUrl, str
public const string ManifestFileName = "staticwebassets.development.json";

public static bool IsScopedCssFile(string filePath)
=> filePath.EndsWith(".razor.css", StringComparison.Ordinal) ||
filePath.EndsWith(".cshtml.css", StringComparison.Ordinal);
// The extension is case-insensitive:
// https://github.com/dotnet/sdk/blob/e62d6a2bd7172fdccd1125bfd5703290ac1b32df/src/StaticWebAssetsSdk/Tasks/DiscoverDefaultScopedCssItems.cs#L36
=> filePath.EndsWith(".razor.css", StringComparison.OrdinalIgnoreCase) ||
filePath.EndsWith(".cshtml.css", StringComparison.OrdinalIgnoreCase);

public static string GetScopedCssRelativeUrl(string applicationProjectFilePath, string containingProjectFilePath)
=> WebRoot + "/" + GetScopedCssBundleFileName(applicationProjectFilePath, containingProjectFilePath);
Expand All @@ -29,17 +33,22 @@ public static string GetScopedCssBundleFileName(string applicationProjectFilePat
{
var sourceProjectName = Path.GetFileNameWithoutExtension(containingProjectFilePath);

return string.Equals(containingProjectFilePath, applicationProjectFilePath, StringComparison.OrdinalIgnoreCase)
return string.Equals(containingProjectFilePath, applicationProjectFilePath, OSSpecificPathComparison)
? $"{sourceProjectName}.styles.css"
: $"{sourceProjectName}.bundle.scp.css";
}

public static bool IsScopedCssBundleFile(string filePath)
=> filePath.EndsWith(".bundle.scp.css", StringComparison.Ordinal) ||
filePath.EndsWith(".styles.css", StringComparison.Ordinal);
// The extension is case-insensitive:
// https://github.com/dotnet/sdk/blob/e62d6a2bd7172fdccd1125bfd5703290ac1b32df/src/StaticWebAssetsSdk/Tasks/ScopedCss/ResolveAllScopedCssAssets.cs#L35
=> filePath.EndsWith(".bundle.scp.css", StringComparison.OrdinalIgnoreCase) ||
filePath.EndsWith(".styles.css", StringComparison.OrdinalIgnoreCase);

public static bool IsCompressedAssetFile(string filePath)
=> filePath.EndsWith(".gz", StringComparison.Ordinal);
// The extension is case-insensitive:
// https://github.com/dotnet/sdk/blob/e62d6a2bd7172fdccd1125bfd5703290ac1b32df/src/StaticWebAssetsSdk/Tasks/Compression/DiscoverPrecompressedAssets.cs#L87
=> filePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase) ||
filePath.EndsWith(".br", StringComparison.OrdinalIgnoreCase);

public static string? GetRelativeUrl(string applicationProjectFilePath, string containingProjectFilePath, string assetFilePath)
=> IsScopedCssFile(assetFilePath)
Expand All @@ -59,4 +68,11 @@ public static bool IsCompressedAssetFile(string filePath)
? assetFilePath.Substring(webRootDir.Length - webRoot.Length).Replace("\\", "/")
: null;
}

/// <summary>
/// Assets with "_content/" prefix live outside the app's  wwwroot.
/// ASP.NET Core's convention for static assets contributed by external sources — Razor Class Libraries (RCLs) and NuGet packages.
/// </summary>
public static bool IsExternalContentUrl(string url)
=> url.StartsWith("_content/", StringComparison.OrdinalIgnoreCase);
}
136 changes: 136 additions & 0 deletions src/Dotnet.Watch/HotReloadClient/Web/StaticWebAssetUpdateBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable enable

using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;

namespace Microsoft.DotNet.HotReload;

internal abstract class StaticWebAssetUpdateBuilder
{
public readonly struct ProjectInstanceInfo
{
public required ProjectInstanceId Id { get; init; }
public required bool HasScopedCssTargets { get; init; }
public required string AssemblyName { get; init; }
}

private readonly Dictionary<ProjectInstanceId, Dictionary<string, StaticWebAsset>> _assets = [];
private readonly HashSet<ProjectInstanceId> _projectInstancesToRegenerate = [];

public IReadOnlyDictionary<ProjectInstanceId, Dictionary<string, StaticWebAsset>> Assets => _assets;

#if NET
public IReadOnlySet<ProjectInstanceId> ProjectInstancesToRegenerate => _projectInstancesToRegenerate;
#else
public IReadOnlyCollection<ProjectInstanceId> ProjectInstancesToRegenerate => _projectInstancesToRegenerate;
#endif

protected abstract bool TryGetManifest(ProjectInstanceId id, [NotNullWhen(true)] out StaticWebAssetsManifest? manifest);
protected abstract IEnumerable<ProjectInstanceInfo> GetProjectInstances(string projectPath);
protected abstract IEnumerable<(ProjectInstanceInfo info, ILogger logger)> GetApplicationProjectAncestors(ProjectInstanceId projectInstanceId);

public void AddAssets(
string filePath,
IEnumerable<string> containingProjectPaths,
string? staticWebAssetRelativeUrl)
{
var isScopedCss = StaticWebAsset.IsScopedCssFile(filePath);
if (!isScopedCss && staticWebAssetRelativeUrl is null)
{
return;
}

foreach (var containingProjectPath in containingProjectPaths)
{
foreach (var containingProjectInstanceInfo in GetProjectInstances(containingProjectPath))
{
if (isScopedCss)
{
if (!containingProjectInstanceInfo.HasScopedCssTargets)
{
continue;
}

_projectInstancesToRegenerate.Add(containingProjectInstanceInfo.Id);
}

// Enumerate running application projects that transitively reference the project containing the changed asset.
//
// For regular asset files contained in Razor Class Libraries we could expect containingProjectPaths to have all the application projects that transitively reference the RCL
// based on the asset manifest (these assets have _content-prefixed URLs). However, scoped CSS files in the RCLs are bundled into a single asset file.
// They are not individually listed in the manifest so we can't map each individual asset's file path to its containing project based on the manifest.
//
// Instead, we only track files directly contained in their own project (Web App or RCL), i.e. the asset's containing project is only the RCL project,
// and then enumerate the web application projects that transitively reference the containing project here.
foreach (var (applicationProjectInstanceInfo, applicationProjectLogger) in GetApplicationProjectAncestors(containingProjectInstanceInfo.Id))
{
string relativeUrl;

if (isScopedCss)
{
// Razor class library may be referenced by application that does not have static assets:
if (!applicationProjectInstanceInfo.HasScopedCssTargets)
{
continue;
}

_projectInstancesToRegenerate.Add(applicationProjectInstanceInfo.Id);

var bundleFileName = StaticWebAsset.GetScopedCssBundleFileName(
applicationProjectFilePath: applicationProjectInstanceInfo.Id.ProjectPath,
containingProjectFilePath: containingProjectInstanceInfo.Id.ProjectPath);

if (!TryGetManifest(applicationProjectInstanceInfo.Id, out var manifest))
{
// Shouldn't happen.
applicationProjectLogger.Log(LogEvents.StaticWebAssetManifestNotFound);
continue;
}

if (!manifest.TryGetBundleFilePath(bundleFileName, out var bundleFilePath))
{
// Shouldn't happen.
applicationProjectLogger.Log(LogEvents.ScopedCssBundleFileNotFound, bundleFileName);
continue;
}

filePath = bundleFilePath;
relativeUrl = bundleFileName;
}
else if (TryGetManifest(applicationProjectInstanceInfo.Id, out var _))
{
Debug.Assert(staticWebAssetRelativeUrl != null);
relativeUrl = staticWebAssetRelativeUrl;
}
else
{
// only refresh static web assets for web application projects (e.g. not for Aspire host app that references a web project)
continue;
}

if (!_assets.TryGetValue(applicationProjectInstanceInfo.Id, out var applicationAssets))
{
applicationAssets = [];
_assets.Add(applicationProjectInstanceInfo.Id, applicationAssets);
}
else if (applicationAssets.ContainsKey(filePath))
{
// asset already being updated in this application project:
continue;
}

applicationAssets.Add(filePath, new StaticWebAsset(
filePath,
StaticWebAsset.WebRoot + "/" + relativeUrl,
containingProjectInstanceInfo.AssemblyName,
isApplicationProject: containingProjectInstanceInfo.Id == applicationProjectInstanceInfo.Id));
}
}
}
}
}
26 changes: 24 additions & 2 deletions src/Dotnet.Watch/HotReloadClient/Web/StaticWebAssetsManifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,34 @@ private sealed class PatternJson
/// <summary>
/// Maps relative URLs to file system paths.
/// </summary>
public ImmutableDictionary<string, string> UrlToPathMap { get; } = urlToPathMap;
public ImmutableDictionary<string, string> UrlToPathMap => urlToPathMap;

/// <summary>
/// List of directory and search pattern pairs for discovering static web assets.
/// </summary>
public ImmutableArray<StaticWebAssetPattern> DiscoveryPatterns { get; } = discoveryPatterns;
public ImmutableArray<StaticWebAssetPattern> DiscoveryPatterns => discoveryPatterns;

/// <summary>
/// Enumerates all the static web assets in the manifest of a project to be watched for changes.
/// </summary>
public IEnumerable<(string filePath, string relativeUrl)> GetFilesToWatch()
{
foreach (var entry in urlToPathMap)
{
var url = entry.Key;
var filePath = entry.Value;

// Exclude bundle files as they are regenarated when scoped CSS files are updated.
// Exclude compressed asset files as they are not directly editable.
// Exclude external content URLs as they are not directly included in the project.
if (!StaticWebAsset.IsCompressedAssetFile(filePath) &&
!StaticWebAsset.IsScopedCssBundleFile(filePath) &&
!StaticWebAsset.IsExternalContentUrl(url))
{
yield return (filePath, url);
}
}
}

public bool TryGetBundleFilePath(string bundleFileName, [NotNullWhen(true)] out string? filePath)
{
Expand Down
8 changes: 2 additions & 6 deletions src/Dotnet.Watch/Watch/Build/EvaluationResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,9 @@ private static void ProcessBuildResults(
{
staticWebAssetManifestsBuilder.Add(projectInstance.GetId(), manifest);

// watch asset files, but not bundle files as they are regenarated when scoped CSS files are updated:
foreach (var (relativeUrl, filePath) in manifest.UrlToPathMap)
foreach (var (filePath, relativeUrl) in manifest.GetFilesToWatch())
{
if (!StaticWebAsset.IsCompressedAssetFile(filePath) && !StaticWebAsset.IsScopedCssBundleFile(filePath))
{
AddFile(filePath, staticWebAssetRelativeUrl: relativeUrl);
}
AddFile(filePath, staticWebAssetRelativeUrl: relativeUrl);
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/Dotnet.Watch/Watch/Build/LoadedProjectGraph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using Microsoft.Build.Evaluation;
using Microsoft.Build.Graph;
using Microsoft.DotNet.HotReload;
using Microsoft.Extensions.Logging;

namespace Microsoft.DotNet.Watch;
Expand Down Expand Up @@ -40,6 +41,9 @@ public IReadOnlyList<ProjectGraphNode> GetProjectNodes(string projectPath)
return [];
}

public ProjectGraphNode GetProjectNode(ProjectInstanceId projectId)
=> _innerBuildNodes[projectId.ProjectPath].Single(n => n.ProjectInstance.GetTargetFramework() == projectId.TargetFramework);

public ProjectGraphNode? TryGetProjectNode(string projectPath, string? targetFramework)
{
var projectNodes = GetProjectNodes(projectPath);
Expand Down
5 changes: 3 additions & 2 deletions src/Dotnet.Watch/Watch/Build/ProjectGraphUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Runtime.Versioning;
using Microsoft.Build.Execution;
using Microsoft.Build.Graph;
using Microsoft.DotNet.HotReload;

namespace Microsoft.DotNet.Watch;

Expand Down Expand Up @@ -57,8 +58,8 @@ public static bool IsWebApp(this ProjectGraphNode projectNode)
public static string? GetOutputDirectory(this ProjectInstance project)
=> project.GetPropertyValue(PropertyNames.TargetPath) is { Length: >0 } path ? Path.GetDirectoryName(Path.Combine(project.Directory, path)) : null;

public static string GetAssemblyName(this ProjectGraphNode projectNode)
=> projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetName);
public static string GetAssemblyName(this ProjectInstance project)
=> project.GetPropertyValue(PropertyNames.TargetName);

public static string? GetIntermediateOutputDirectory(this ProjectInstance project)
=> project.GetPropertyValue(PropertyNames.IntermediateOutputPath) is { Length: >0 } path ? Path.Combine(project.Directory, path) : null;
Expand Down
Loading
Loading