From 71ec0a8d47c3bfa9381cc7816586ef5b2c0f56ae Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 3 Apr 2026 11:39:25 -0700 Subject: [PATCH] Write Vite HTTPS config wrapper to node_modules/.aspire instead of node_modules/.bin The generated aspire.vite.config wrapper was written to node_modules/.bin/ which doesn't exist when package managers hoist dependencies (e.g. yarn workspaces monorepos). Walk up from the app directory to find the nearest node_modules and write the wrapper to a .aspire subdirectory inside it. This ensures Node.js module resolution can find bare imports like 'vite' while also handling hoisted dependency trees. The import for the user's original config uses a relative path from the wrapper location. The SubscribeHttpsEndpointsUpdate callback unconditionally flips the endpoint scheme to HTTPS (the user opted in). The cert config callback retains the original precedence for determining which config to wrap: explicit --config argument > WithViteConfig > auto-detect default config files. Fixes #15853 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JavaScriptHostingExtensions.cs | 65 ++++++++-- .../AddViteAppTests.cs | 116 +++++++++--------- 2 files changed, 115 insertions(+), 66 deletions(-) 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;