diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index ca8369aa17f..0693feac937 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -36,18 +36,18 @@ public static class JavaScriptHostingExtensions private static readonly string[] s_defaultConfigFiles = ["vite.config.js", "vite.config.mjs", "vite.config.ts", "vite.config.cjs", "vite.config.mts", "vite.config.cts"]; // The token to replace with the relative path to the user's Vite config file - private const string AspireViteRelativeConfigToken = "%%ASPIRE_VITE_RELATIVE_CONFIG_PATH%%"; + private const string AspireViteConfigPathToken = "%%ASPIRE_VITE_CONFIG_PATH%%"; // The token to replace with the absolute path to the original Vite config file private const string AspireViteAbsoluteConfigToken = "%%ASPIRE_VITE_ABSOLUTE_CONFIG_PATH%%"; // A template Vite config that loads an existing config provides a default https configuration if one isn't present // Uses environment variables to configure a TLS certificate in PFX format and its password if specified - // The value of %%ASPIRE_VITE_RELATIVE_CONFIG_PATH%% is replaced with the path to the user's actual Vite config file at runtime + // The value of %%ASPIRE_VITE_CONFIG_PATH%% is replaced with the relative path to the user's actual Vite config file at runtime // Vite only supports module style config files, so we don't have to handle commonjs style imports or exports here private const string AspireViteConfig = """ import { defineConfig } from 'vite' - import config from '%%ASPIRE_VITE_RELATIVE_CONFIG_PATH%%' + import config from '%%ASPIRE_VITE_CONFIG_PATH%%' console.log('Applying Aspire specific Vite configuration for HTTPS support.') console.log('Found original Vite configuration at "%%ASPIRE_VITE_ABSOLUTE_CONFIG_PATH%%"') @@ -998,18 +998,35 @@ public static IResourceBuilder AddViteApp(this IDistributedAppl { // Determine the absolute path to the original config file var absoluteConfigPath = Path.GetFullPath(configTarget, appDirectory); - // Determine the relative path from the Aspire vite config to the original config file - var relativeConfigPath = Path.GetRelativePath(Path.Join(appDirectory, "node_modules", ".bin"), absoluteConfigPath); - // If we are expecting to run the vite app with HTTPS termination, generate an Aspire specific Vite config file that can mutate the user's original config + // Find the nearest node_modules directory by walking up from the app directory. + // This handles package managers that hoist dependencies (e.g. yarn workspaces) + // where node_modules lives at the repo root rather than in the app directory. + // Writing inside node_modules ensures Node.js module resolution can find + // bare imports like 'vite' in the generated wrapper config. + var nodeModulesDir = FindNearestNodeModules(appDirectory); + if (nodeModulesDir is null) + { + var resourceLoggerService = ctx.ExecutionContext.ServiceProvider.GetRequiredService(); + var resourceLogger = resourceLoggerService.GetLogger(resource); + resourceLogger.LogWarning("Could not find a node_modules directory in or above '{AppDirectory}' for resource '{ResourceName}'. Automatic HTTPS configuration won't be available. Ensure packages are installed before starting the app.", appDirectory, resource.Name); + return; + } + + var aspireConfigDir = Path.Join(nodeModulesDir, ".aspire"); + Directory.CreateDirectory(aspireConfigDir); + + // Compute the relative path from the wrapper location to the original config + var relativeConfigPath = Path.GetRelativePath(aspireConfigDir, absoluteConfigPath).Replace("\\", "/"); + + // Generate an Aspire specific Vite config file that wraps the user's original config with HTTPS support var aspireConfig = AspireViteConfig - .Replace(AspireViteRelativeConfigToken, relativeConfigPath.Replace("\\", "/"), StringComparison.Ordinal) + .Replace(AspireViteConfigPathToken, relativeConfigPath, StringComparison.Ordinal) .Replace(AspireViteAbsoluteConfigToken, absoluteConfigPath.Replace("\\", "\\\\"), StringComparison.Ordinal); - var aspireConfigPath = Path.Join(appDirectory, "node_modules", ".bin", $"aspire.{Path.GetFileName(configTarget)}"); + var aspireConfigPath = Path.Join(aspireConfigDir, $"aspire.{Path.GetFileName(configTarget)}"); File.WriteAllText(aspireConfigPath, aspireConfig); - // Override the path to the Vite config file to use the Aspire generated one. If we made it here, we - // know there isn't an existing --config argument present. + // Override the path to the Vite config file to use the Aspire generated one ctx.Arguments.Add("--config"); ctx.Arguments.Add(aspireConfigPath); @@ -1039,7 +1056,8 @@ public static IResourceBuilder AddViteApp(this IDistributedAppl if (builder.ExecutionContext.IsRunMode) { // Vite only supports a single endpoint, so we have to modify the existing endpoint to use HTTPS instead of - // adding a new one. + // adding a new one. The user explicitly opted into HTTPS via WithHttpsDeveloperCertificate(), so the scheme + // change is unconditional here. resourceBuilder.SubscribeHttpsEndpointsUpdate(ctx => { resourceBuilder.WithEndpoint("http", ep => ep.UriScheme = "https"); @@ -1795,6 +1813,31 @@ private static void ValidateApiPath(string apiPath) } } + /// + /// Walks up from to find the nearest node_modules directory. + /// + private static string? FindNearestNodeModules(string startDirectory) + { + var current = Path.GetFullPath(startDirectory); + while (current is not null) + { + var candidate = Path.Join(current, "node_modules"); + if (Directory.Exists(candidate)) + { + return candidate; + } + + var parent = Path.GetDirectoryName(current); + if (parent == current) + { + break; + } + current = parent; + } + + return null; + } + private static string NormalizeRelativePath(string path) { var normalizedPath = path.Replace('\\', '/'); diff --git a/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppTests.cs index c8bed4f16a9..d01aff12898 100644 --- a/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppTests.cs +++ b/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppTests.cs @@ -6,6 +6,7 @@ #pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only #pragma warning disable ASPIREEXTENSION001 // Type is for evaluation purposes only +using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Tests.Utils; @@ -500,9 +501,8 @@ public async Task AddViteApp_ServerAuthCertConfig_WithExistingConfigArgument_Rep { using var tempDir = new TestTempDirectory(); - // Create node_modules/.bin directory for Aspire config generation - var nodeModulesBinDir = Path.Combine(tempDir.Path, "node_modules", ".bin"); - Directory.CreateDirectory(nodeModulesBinDir); + // Create node_modules directory for wrapper config generation + Directory.CreateDirectory(Path.Combine(tempDir.Path, "node_modules")); // Create a vite config file var viteConfigPath = Path.Combine(tempDir.Path, "vite.config.js"); @@ -528,7 +528,7 @@ public async Task AddViteApp_ServerAuthCertConfig_WithExistingConfigArgument_Rep var context = new HttpsCertificateConfigurationCallbackAnnotationContext { - ExecutionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + ExecutionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { ServiceProvider = app.Services }), Resource = nodeResource, Arguments = args, EnvironmentVariables = env, @@ -542,14 +542,14 @@ public async Task AddViteApp_ServerAuthCertConfig_WithExistingConfigArgument_Rep // Invoke the callback await certConfigAnnotation.Callback(context); - // Verify a new --config was added with Aspire-specific path + // Verify the existing --config was replaced with the Aspire wrapper path var configIndex = args.IndexOf("--config"); Assert.True(configIndex >= 0); Assert.True(configIndex + 1 < args.Count); var newConfigPath = args[configIndex + 1] as string; Assert.NotNull(newConfigPath); Assert.Contains("aspire.", newConfigPath); - Assert.Contains("node_modules", newConfigPath); + Assert.Contains(Path.Combine("node_modules", ".aspire"), newConfigPath); // Verify environment variables were set Assert.Contains("TLS_CONFIG_PFX", env.Keys); @@ -561,9 +561,8 @@ public async Task AddViteApp_ServerAuthCertConfig_WithoutExistingConfigArgument_ { using var tempDir = new TestTempDirectory(); - // Create node_modules/.bin directory for Aspire config generation - var nodeModulesBinDir = Path.Combine(tempDir.Path, "node_modules", ".bin"); - Directory.CreateDirectory(nodeModulesBinDir); + // Create node_modules directory for wrapper config generation + Directory.CreateDirectory(Path.Combine(tempDir.Path, "node_modules")); // Create a default vite config file that would be auto-detected var viteConfigPath = Path.Combine(tempDir.Path, "vite.config.js"); @@ -582,13 +581,13 @@ public async Task AddViteApp_ServerAuthCertConfig_WithoutExistingConfigArgument_ .OfType() .Single(); - // Set up a context without --config argument (simulating default behavior) + // Set up a context without --config argument var args = new List { "run", "dev", "--", "--port", "3000" }; var env = new Dictionary(); var context = new HttpsCertificateConfigurationCallbackAnnotationContext { - ExecutionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + ExecutionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { ServiceProvider = app.Services }), Resource = nodeResource, Arguments = args, EnvironmentVariables = env, @@ -628,6 +627,8 @@ public async Task AddViteApp_ServerAuthCertConfig_WithMissingConfigFile_DoesNotA var appModel = app.Services.GetRequiredService(); var nodeResource = Assert.Single(appModel.Resources.OfType()); + // AspireHttpsConfigPath is null (SubscribeHttpsEndpointsUpdate did not generate a wrapper) + // Get the HttpsCertificateConfigurationCallbackAnnotation var certConfigAnnotation = nodeResource.Annotations .OfType() @@ -653,10 +654,10 @@ public async Task AddViteApp_ServerAuthCertConfig_WithMissingConfigFile_DoesNotA // Invoke the callback await certConfigAnnotation.Callback(context); - // Verify no --config was added since no default config file exists + // Verify no --config was added Assert.DoesNotContain("--config", args); - // Environment variables should NOT be set if there was no config to wrap + // Environment variables should NOT be set Assert.Empty(env); } @@ -665,9 +666,8 @@ public async Task AddViteApp_ServerAuthCertConfig_WithPassword_SetsPasswordEnvir { using var tempDir = new TestTempDirectory(); - // Create node_modules/.bin directory for Aspire config generation - var nodeModulesBinDir = Path.Combine(tempDir.Path, "node_modules", ".bin"); - Directory.CreateDirectory(nodeModulesBinDir); + // Create node_modules directory for wrapper config generation + Directory.CreateDirectory(Path.Combine(tempDir.Path, "node_modules")); // Create a vite config file var viteConfigPath = Path.Combine(tempDir.Path, "vite.config.js"); @@ -695,7 +695,7 @@ public async Task AddViteApp_ServerAuthCertConfig_WithPassword_SetsPasswordEnvir var context = new HttpsCertificateConfigurationCallbackAnnotationContext { - ExecutionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + ExecutionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { ServiceProvider = app.Services }), Resource = nodeResource, Arguments = args, EnvironmentVariables = env, @@ -716,43 +716,44 @@ public async Task AddViteApp_ServerAuthCertConfig_WithPassword_SetsPasswordEnvir } [Fact] - public async Task AddViteApp_ServerAuthCertConfig_EscapesBackslashesInAbsoluteConfigPath() + public async Task AddViteApp_HttpsEndpointsUpdate_WritesWrapperToNearestNodeModules() { using var tempDir = new TestTempDirectory(); - // Create a subdirectory path that will contain backslashes on Windows - var subDir = Path.Combine(tempDir.Path, "my-app", "frontend"); - Directory.CreateDirectory(subDir); - - // Create node_modules/.bin directory for Aspire config generation - var nodeModulesBinDir = Path.Combine(subDir, "node_modules", ".bin"); - Directory.CreateDirectory(nodeModulesBinDir); + // Simulate a hoisted monorepo layout: node_modules is at the repo root, not in the app directory + var repoRoot = Path.Combine(tempDir.Path, "repo"); + var appDir = Path.Combine(repoRoot, "packages", "frontend"); + Directory.CreateDirectory(appDir); + Directory.CreateDirectory(Path.Combine(repoRoot, "node_modules")); - // Create a vite config file - var viteConfigPath = Path.Combine(subDir, "vite.config.js"); + // Create a vite config file in the app directory + var viteConfigPath = Path.Combine(appDir, "vite.config.ts"); File.WriteAllText(viteConfigPath, "export default {}"); - var builder = DistributedApplication.CreateBuilder(); - var viteApp = builder.AddViteApp("test-app", subDir); + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + builder.AddViteApp("test-app", appDir) + .WithHttpsDeveloperCertificate(); using var app = builder.Build(); + // Execute the before-start hooks which triggers SubscribeHttpsEndpointsUpdate (endpoint scheme change) + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); + var appModel = app.Services.GetRequiredService(); - var nodeResource = Assert.Single(appModel.Resources.OfType()); + var viteResource = Assert.Single(appModel.Resources.OfType()); - // Get the HttpsCertificateConfigurationCallbackAnnotation - var certConfigAnnotation = nodeResource.Annotations + // Now invoke the cert config callback which generates the wrapper file + var certConfigAnnotation = viteResource.Annotations .OfType() .Single(); - // Set up a context without --config argument (simulating default behavior) var args = new List { "run", "dev", "--", "--port", "3000" }; var env = new Dictionary(); var context = new HttpsCertificateConfigurationCallbackAnnotationContext { - ExecutionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), - Resource = nodeResource, + ExecutionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { ServiceProvider = app.Services }), + Resource = viteResource, Arguments = args, EnvironmentVariables = env, CertificatePath = ReferenceExpression.Create($"cert.pem"), @@ -762,30 +763,33 @@ public async Task AddViteApp_ServerAuthCertConfig_EscapesBackslashesInAbsoluteCo CancellationToken = CancellationToken.None }; - // Invoke the callback await certConfigAnnotation.Callback(context); - // Verify a --config was added with Aspire-specific path + // Verify the wrapper was written under the hoisted node_modules/.aspire (repo root, not app dir) + var expectedDir = Path.Combine(repoRoot, "node_modules", ".aspire"); + Assert.True(Directory.Exists(expectedDir), $"Expected .aspire directory at {expectedDir}"); + + var wrapperFiles = Directory.GetFiles(expectedDir, "aspire.vite.config.ts"); + Assert.Single(wrapperFiles); + + // Verify the --config argument points to the wrapper in the hoisted location var configIndex = args.IndexOf("--config"); Assert.True(configIndex >= 0); - Assert.True(configIndex + 1 < args.Count); - var newConfigPath = args[configIndex + 1] as string; - Assert.NotNull(newConfigPath); + var configPath = args[configIndex + 1] as string; + Assert.NotNull(configPath); + Assert.StartsWith(expectedDir, configPath); - // Read the generated Aspire Vite config file - var generatedConfigContent = File.ReadAllText(newConfigPath); + // Verify wrapper content + var wrapperContent = File.ReadAllText(wrapperFiles[0]); - // Verify the generated config contains the absolute path with properly escaped backslashes - // The absolute path should have backslashes escaped as \\\\ in the JavaScript string + // The import path should be a relative path with forward slashes (no backslashes) + Assert.Contains("import config from '", wrapperContent); + Assert.DoesNotMatch(@"import config from '[^']*\\[^']*'", wrapperContent); + + // The console.log line should contain properly escaped backslashes for JavaScript var absoluteConfigPath = Path.GetFullPath(viteConfigPath); var expectedEscapedPath = absoluteConfigPath.Replace("\\", "\\\\"); - - Assert.Contains($"console.log('Found original Vite configuration at \"{expectedEscapedPath}\"')", generatedConfigContent); - - // Verify the import statement uses forward slashes for the relative path - Assert.Contains("import config from '", generatedConfigContent); - // The import path should use forward slashes (not backslashes) - Assert.DoesNotMatch(@"import config from '[^']*\\[^']*'", generatedConfigContent); + Assert.Contains($"Found original Vite configuration at \"{expectedEscapedPath}\"", wrapperContent); } [Theory] @@ -799,9 +803,8 @@ public async Task AddViteApp_ServerAuthCertConfig_DetectsAllDefaultConfigFileFor { using var tempDir = new TestTempDirectory(); - // Create node_modules/.bin directory for Aspire config generation - var nodeModulesBinDir = Path.Combine(tempDir.Path, "node_modules", ".bin"); - Directory.CreateDirectory(nodeModulesBinDir); + // Create node_modules directory for wrapper config generation + Directory.CreateDirectory(Path.Combine(tempDir.Path, "node_modules")); // Create the specific config file format var viteConfigPath = Path.Combine(tempDir.Path, configFileName); @@ -826,7 +829,7 @@ public async Task AddViteApp_ServerAuthCertConfig_DetectsAllDefaultConfigFileFor var context = new HttpsCertificateConfigurationCallbackAnnotationContext { - ExecutionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + ExecutionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { ServiceProvider = app.Services }), Resource = nodeResource, Arguments = args, EnvironmentVariables = env, @@ -910,6 +913,9 @@ public async Task NextJsStandaloneCheckFailsInPipelineWhenMissing() } // Helper class for testing IValueProvider + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] + private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); + private sealed class TestValueProvider : IValueProvider { private readonly string _value;