Skip to content

Commit d30f08f

Browse files
danegstaCopilot
andcommitted
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 an absolute path so it resolves correctly regardless of where the wrapper lives. The config wrapper generation is moved to the SubscribeHttpsEndpointsUpdate callback (which runs before the cert config callback) so the endpoint scheme is only changed to HTTPS when the wrapper was successfully created. If no node_modules directory is found, a warning is logged and HTTPS config is skipped gracefully. Fixes #15853 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6be0858 commit d30f08f

3 files changed

Lines changed: 152 additions & 158 deletions

File tree

src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs

Lines changed: 93 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -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('\\', '/');

src/Aspire.Hosting.JavaScript/ViteAppResource.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,9 @@ public class ViteAppResource(string name, string command, string workingDirector
1717
/// Gets or sets the path to the Vite configuration file.
1818
/// </summary>
1919
internal string? ViteConfigPath { get; set; }
20+
21+
/// <summary>
22+
/// Gets or sets the path to the generated Aspire HTTPS config wrapper, if successfully created.
23+
/// </summary>
24+
internal string? AspireHttpsConfigPath { get; set; }
2025
}

0 commit comments

Comments
 (0)