@@ -35,19 +35,19 @@ public static class JavaScriptHostingExtensions
3535 // See https://github.com/vitejs/vite/blob/main/packages/vite/src/node/constants.ts#L97
3636 private static readonly string [ ] s_defaultConfigFiles = [ "vite.config.js" , "vite.config.mjs" , "vite.config.ts" , "vite.config.cjs" , "vite.config.mts" , "vite.config.cts" ] ;
3737
38- // The token to replace with the relative path to the user's Vite config file
39- private const string AspireViteRelativeConfigToken = "%%ASPIRE_VITE_RELATIVE_CONFIG_PATH %%" ;
38+ // The token to replace with the path to the user's Vite config file
39+ private const string AspireViteConfigPathToken = "%%ASPIRE_VITE_CONFIG_PATH %%" ;
4040
4141 // The token to replace with the absolute path to the original Vite config file
4242 private const string AspireViteAbsoluteConfigToken = "%%ASPIRE_VITE_ABSOLUTE_CONFIG_PATH%%" ;
4343
4444 // A template Vite config that loads an existing config provides a default https configuration if one isn't present
4545 // Uses environment variables to configure a TLS certificate in PFX format and its password if specified
46- // The value of %%ASPIRE_VITE_RELATIVE_CONFIG_PATH %% is replaced with the path to the user's actual Vite config file at runtime
46+ // The value of %%ASPIRE_VITE_CONFIG_PATH %% is replaced with the absolute path to the user's actual Vite config file at runtime
4747 // Vite only supports module style config files, so we don't have to handle commonjs style imports or exports here
4848 private const string AspireViteConfig = """
4949 import { defineConfig } from 'vite'
50- import config from '%%ASPIRE_VITE_RELATIVE_CONFIG_PATH %%'
50+ import config from '%%ASPIRE_VITE_CONFIG_PATH %%'
5151
5252 console.log('Applying Aspire specific Vite configuration for HTTPS support.')
5353 console.log('Found original Vite configuration at "%%ASPIRE_VITE_ABSOLUTE_CONFIG_PATH%%"')
@@ -949,25 +949,17 @@ public static IResourceBuilder<ViteAppResource> AddViteApp(this IDistributedAppl
949949 . WithoutHttpsCertificate ( )
950950 . WithHttpsCertificateConfiguration ( async ctx =>
951951 {
952- string ? configTarget = resource . ViteConfigPath ;
952+ // The Aspire HTTPS config wrapper is generated in SubscribeHttpsEndpointsUpdate below
953+ // (which runs first). Here we just apply the --config argument and TLS env vars if it succeeded.
954+ if ( resource . AspireHttpsConfigPath is null )
955+ {
956+ return ;
957+ }
953958
954- // First we need to determine if there's an existing --config argument specified
959+ // First we need to determine if there's an existing --config argument specified and remove it
955960 var cfgIndex = ctx . Arguments . IndexOf ( "--config" ) ;
956961 if ( cfgIndex >= 0 && cfgIndex + 1 < ctx . Arguments . Count )
957962 {
958- configTarget = ctx . Arguments [ cfgIndex + 1 ] switch
959- {
960- string s when ! string . IsNullOrEmpty ( s ) && ! s . StartsWith ( "--" ) => s ,
961- ReferenceExpression re => await re . GetValueAsync ( ctx . CancellationToken ) . ConfigureAwait ( false ) ,
962- _ => null ,
963- } ;
964-
965- if ( string . IsNullOrEmpty ( configTarget ) )
966- {
967- // Couldn't determine the config target, so don't modify anything
968- return ;
969- }
970-
971963 // Remove the original --config argument and its value
972964 ctx . Arguments . RemoveAt ( cfgIndex ) ;
973965 ctx . Arguments . RemoveAt ( cfgIndex ) ;
@@ -978,9 +970,28 @@ public static IResourceBuilder<ViteAppResource> AddViteApp(this IDistributedAppl
978970 return ;
979971 }
980972
973+ ctx . Arguments . Add ( "--config" ) ;
974+ ctx . Arguments . Add ( resource . AspireHttpsConfigPath ) ;
975+
976+ ctx . EnvironmentVariables [ "TLS_CONFIG_PFX" ] = ctx . PfxPath ;
977+ if ( ctx . Password is not null )
978+ {
979+ ctx . EnvironmentVariables [ "TLS_CONFIG_PASSWORD" ] = ctx . Password ;
980+ }
981+ } ) ;
982+
983+ if ( builder . ExecutionContext . IsRunMode )
984+ {
985+ // Vite only supports a single endpoint, so we have to modify the existing endpoint to use HTTPS instead of
986+ // adding a new one. This callback runs before the certificate config callback, so we generate the Aspire
987+ // HTTPS config wrapper here and store the path on the resource for the cert callback to use.
988+ resourceBuilder . SubscribeHttpsEndpointsUpdate ( ctx =>
989+ {
990+ string ? configTarget = resource . ViteConfigPath ;
991+
992+ // If the user specified a config via WithViteConfig, use it; otherwise auto-detect
981993 if ( string . IsNullOrEmpty ( configTarget ) )
982994 {
983- // The user didn't specify a specific vite config file, so we need to look for one of the default config files
984995 foreach ( var configFile in s_defaultConfigFiles )
985996 {
986997 var candidatePath = Path . GetFullPath ( Path . Join ( appDirectory , configFile ) ) ;
@@ -992,57 +1003,49 @@ public static IResourceBuilder<ViteAppResource> AddViteApp(this IDistributedAppl
9921003 }
9931004 }
9941005
995- if ( configTarget is not null )
1006+ if ( configTarget is null )
9961007 {
997- try
998- {
999- // Determine the absolute path to the original config file
1000- var absoluteConfigPath = Path . GetFullPath ( configTarget , appDirectory ) ;
1001- // Determine the relative path from the Aspire vite config to the original config file
1002- var relativeConfigPath = Path . GetRelativePath ( Path . Join ( appDirectory , "node_modules" , ".bin" ) , absoluteConfigPath ) ;
1003-
1004- // 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
1005- var aspireConfig = AspireViteConfig
1006- . Replace ( AspireViteRelativeConfigToken , relativeConfigPath . Replace ( "\\ " , "/" ) , StringComparison . Ordinal )
1007- . Replace ( AspireViteAbsoluteConfigToken , absoluteConfigPath . Replace ( "\\ " , "\\ \\ " ) , StringComparison . Ordinal ) ;
1008- var aspireConfigPath = Path . Join ( appDirectory , "node_modules" , ".bin" , $ "aspire.{ Path . GetFileName ( configTarget ) } ") ;
1009- File . WriteAllText ( aspireConfigPath , aspireConfig ) ;
1010-
1011- // Override the path to the Vite config file to use the Aspire generated one. If we made it here, we
1012- // know there isn't an existing --config argument present.
1013- ctx . Arguments . Add ( "--config" ) ;
1014- ctx . Arguments . Add ( aspireConfigPath ) ;
1015-
1016- ctx . EnvironmentVariables [ "TLS_CONFIG_PFX" ] = ctx . PfxPath ;
1017- if ( ctx . Password is not null )
1018- {
1019- ctx . EnvironmentVariables [ "TLS_CONFIG_PASSWORD" ] = ctx . Password ;
1020- }
1021- }
1022- catch ( Exception ex )
1008+ return ;
1009+ }
1010+
1011+ try
1012+ {
1013+ var absoluteConfigPath = Path . GetFullPath ( configTarget , appDirectory ) ;
1014+
1015+ // Find the nearest node_modules directory by walking up from the app directory.
1016+ // This handles package managers that hoist dependencies (e.g. yarn workspaces)
1017+ // where node_modules lives at the repo root rather than in the app directory.
1018+ // Writing inside node_modules ensures Node.js module resolution can find
1019+ // bare imports like 'vite' in the generated wrapper config.
1020+ var nodeModulesDir = FindNearestNodeModules ( appDirectory ) ;
1021+ if ( nodeModulesDir is null )
10231022 {
1024- var resourceLoggerService = ctx . ExecutionContext . ServiceProvider . GetRequiredService < ResourceLoggerService > ( ) ;
1023+ var resourceLoggerService = ctx . Services . GetRequiredService < ResourceLoggerService > ( ) ;
10251024 var resourceLogger = resourceLoggerService . GetLogger ( resource ) ;
1025+ 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 ) ;
1026+ return ;
1027+ }
10261028
1027- resourceLogger . LogWarning ( ex , "Failed to generate Aspire Vite HTTPS config wrapper for resource '{ResourceName}'. Falling back to existing Vite config without Aspire modifications. Automatic HTTPS configuration won't be available" , resource . Name ) ;
1029+ var aspireConfigDir = Path . Join ( nodeModulesDir , ".aspire" ) ;
1030+ Directory . CreateDirectory ( aspireConfigDir ) ;
10281031
1029- if ( ! string . IsNullOrEmpty ( configTarget ) )
1030- {
1031- // Fallback to using the existing config target
1032- ctx . Arguments . Add ( "--config" ) ;
1033- ctx . Arguments . Add ( configTarget ) ;
1034- }
1035- }
1036- }
1037- } ) ;
1032+ var importPath = absoluteConfigPath . Replace ( "\\ " , "/" ) ;
10381033
1039- if ( builder . ExecutionContext . IsRunMode )
1040- {
1041- // Vite only supports a single endpoint, so we have to modify the existing endpoint to use HTTPS instead of
1042- // adding a new one.
1043- resourceBuilder . SubscribeHttpsEndpointsUpdate ( ctx =>
1044- {
1045- resourceBuilder . WithEndpoint ( "http" , ep => ep . UriScheme = "https" ) ;
1034+ var aspireConfig = AspireViteConfig
1035+ . Replace ( AspireViteConfigPathToken , importPath , StringComparison . Ordinal )
1036+ . Replace ( AspireViteAbsoluteConfigToken , absoluteConfigPath . Replace ( "\\ " , "\\ \\ " ) , StringComparison . Ordinal ) ;
1037+ var aspireConfigPath = Path . Join ( aspireConfigDir , $ "aspire.{ Path . GetFileName ( configTarget ) } ") ;
1038+ File . WriteAllText ( aspireConfigPath , aspireConfig ) ;
1039+
1040+ resource . AspireHttpsConfigPath = aspireConfigPath ;
1041+ resourceBuilder . WithEndpoint ( "http" , ep => ep . UriScheme = "https" ) ;
1042+ }
1043+ catch ( Exception ex )
1044+ {
1045+ var resourceLoggerService = ctx . Services . GetRequiredService < ResourceLoggerService > ( ) ;
1046+ var resourceLogger = resourceLoggerService . GetLogger ( resource ) ;
1047+ resourceLogger . LogWarning ( ex , "Failed to generate Aspire Vite HTTPS config wrapper for resource '{ResourceName}'. Falling back to existing Vite config without Aspire modifications. Automatic HTTPS configuration won't be available" , resource . Name ) ;
1048+ }
10461049 } ) ;
10471050 }
10481051
@@ -1795,6 +1798,31 @@ private static void ValidateApiPath(string apiPath)
17951798 }
17961799 }
17971800
1801+ /// <summary>
1802+ /// Walks up from <paramref name="startDirectory"/> to find the nearest <c>node_modules</c> directory.
1803+ /// </summary>
1804+ private static string ? FindNearestNodeModules ( string startDirectory )
1805+ {
1806+ var current = Path . GetFullPath ( startDirectory ) ;
1807+ while ( current is not null )
1808+ {
1809+ var candidate = Path . Join ( current , "node_modules" ) ;
1810+ if ( Directory . Exists ( candidate ) )
1811+ {
1812+ return candidate ;
1813+ }
1814+
1815+ var parent = Path . GetDirectoryName ( current ) ;
1816+ if ( parent == current )
1817+ {
1818+ break ;
1819+ }
1820+ current = parent ;
1821+ }
1822+
1823+ return null ;
1824+ }
1825+
17981826 private static string NormalizeRelativePath ( string path )
17991827 {
18001828 var normalizedPath = path . Replace ( '\\ ' , '/' ) ;
0 commit comments