From fb486033587cb3e73c0dd44cca460a0d033d8819 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 22 Oct 2025 13:11:45 -0500 Subject: [PATCH 01/11] Add ViteApp and NpmInstallation Remove the dependency on Community Toolkit in the py template. Contributes to #12199 --- Aspire.slnx | 14 +-- .../AspireJavaScript.AppHost/AppHost.cs | 6 +- .../JavaScriptPackageInstallerAnnotation.cs | 17 +++ .../JavaScriptPackageManagerAnnotation.cs | 24 ++++ src/Aspire.Hosting.NodeJs/NodeExtensions.cs | 111 ++++++++++++++++++ .../NpmInstallerResource.cs | 14 +++ .../aspire-py-starter/13.0/apphost.cs | 3 +- 7 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 src/Aspire.Hosting.NodeJs/JavaScriptPackageInstallerAnnotation.cs create mode 100644 src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs create mode 100644 src/Aspire.Hosting.NodeJs/NpmInstallerResource.cs diff --git a/Aspire.slnx b/Aspire.slnx index d2ce1f43147..2dab93a8f6a 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -106,6 +106,11 @@ + + + + + @@ -176,8 +181,8 @@ - + @@ -199,11 +204,6 @@ - - - - - @@ -234,8 +234,8 @@ - + diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs index 86b7914f5b1..e26811a5ec1 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs +++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs @@ -1,4 +1,4 @@ -var builder = DistributedApplication.CreateBuilder(args); +var builder = DistributedApplication.CreateBuilder(args); var weatherApi = builder.AddProject("weatherapi") .WithExternalHttpEndpoints(); @@ -25,10 +25,10 @@ .WithExternalHttpEndpoints() .PublishAsDockerFile(); -builder.AddNpmApp("reactvite", "../AspireJavaScript.Vite") +builder.AddViteApp("reactvite", "../AspireJavaScript.Vite") + .WithNpmPackageManager() .WithReference(weatherApi) .WithEnvironment("BROWSER", "none") - .WithHttpEndpoint(env: "VITE_PORT") .WithExternalHttpEndpoints() .PublishAsDockerFile(); diff --git a/src/Aspire.Hosting.NodeJs/JavaScriptPackageInstallerAnnotation.cs b/src/Aspire.Hosting.NodeJs/JavaScriptPackageInstallerAnnotation.cs new file mode 100644 index 00000000000..bdeb1ee3053 --- /dev/null +++ b/src/Aspire.Hosting.NodeJs/JavaScriptPackageInstallerAnnotation.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.NodeJs; + +/// +/// Represents an annotation for a JavaScript installer resource. +/// +public sealed class JavaScriptPackageInstallerAnnotation(ExecutableResource installerResource) : IResourceAnnotation +{ + /// + /// The instance of the Installer resource used. + /// + public ExecutableResource Resource { get; } = installerResource; +} \ No newline at end of file diff --git a/src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs b/src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs new file mode 100644 index 00000000000..038d7cb6ab1 --- /dev/null +++ b/src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.NodeJs; + +/// +/// Represents the annotation for the JavaScript package manager used in a resource. +/// +/// The name of the JavaScript package manager. +public sealed class JavaScriptPackageManagerAnnotation(string packageManager) : IResourceAnnotation +{ + /// + /// Gets the name of the JavaScript package manager. + /// + public string PackageManager { get; } = packageManager; + + /// + /// Gets the command line arguments for the JavaScript package manager. + /// + public string[] CommandLineArgs { get; init; } = []; + +} \ No newline at end of file diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs index 4bdd94818c8..d69b4528905 100644 --- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs +++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs @@ -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. + using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.NodeJs; using Aspire.Hosting.Utils; using Microsoft.Extensions.Hosting; @@ -86,4 +88,113 @@ private static IResourceBuilder WithNodeDefaults(this IResource return Task.CompletedTask; }); + + /// + /// Adds a Vite app to the distributed application builder. + /// + /// The to add the resource to. + /// The name of the Vite app. + /// The working directory of the Vite app. + /// When true use HTTPS for the endpoints, otherwise use HTTP. + /// A reference to the . + /// This uses the specified package manager (default npm) method internally but sets defaults that would be expected to run a Vite app, such as the command to run the dev server and exposing the HTTP endpoints. + public static IResourceBuilder AddViteApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string? workingDirectory = null, bool useHttps = false) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(workingDirectory); + + workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory)); + var resource = new NodeAppResource(name, "node", workingDirectory); + + var resourceBuilder = builder.AddResource(resource) + .WithNodeDefaults() + .WithArgs(c => + { + if (c.Resource.TryGetLastAnnotation(out var packageManagerAnnotation)) + { + foreach (var arg in packageManagerAnnotation.CommandLineArgs) + { + c.Args.Add(arg); + } + } + c.Args.Add("dev"); + }) + .WithIconName("CodeJsRectangle"); + + _ = useHttps + ? resourceBuilder.WithHttpsEndpoint(env: "PORT") + : resourceBuilder.WithHttpEndpoint(env: "PORT"); + + resourceBuilder.WithMappedEndpointPort(); + + return resourceBuilder; + } + + /// + /// Maps the endpoint port for the to the appropriate command line argument. + /// + /// The Node.js app resource. + /// The name of the endpoint to map. If not specified, it will use the first HTTP or HTTPS endpoint found. + /// A reference to the . + public static IResourceBuilder WithMappedEndpointPort(this IResourceBuilder builder, string? endpointName = null) where TResource : NodeAppResource + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithArgs(ctx => + { + var resource = builder.Resource; + + // monorepo tools will need `--`, as does npm + if (!resource.TryGetLastAnnotation(out var packageManagerAnnotation) || packageManagerAnnotation.PackageManager == "npm") + { + ctx.Args.Add("--"); + } + + // Find the target endpoint by name, or default to http/https if no name specified + var targetEndpoint = endpointName is not null + ? resource.GetEndpoint(endpointName) + : resource.GetEndpoints().FirstOrDefault(e => e.EndpointName == "https") ?? resource.GetEndpoint("http"); + + ctx.Args.Add("--port"); + ctx.Args.Add(targetEndpoint.Property(EndpointProperty.TargetPort)); + }); + } + + /// + /// Ensures the Node.js packages are installed before the application starts using npm as the package manager. + /// + /// The Node.js app resource. + /// When true use npm ci otherwise use npm install when installing packages. + /// Configure the npm installer resource. + /// A reference to the . + public static IResourceBuilder WithNpmPackageManager(this IResourceBuilder resource, bool useCI = false, Action>? configureInstaller = null) + { + resource.WithCommand("npm"); + resource.WithAnnotation(new JavaScriptPackageManagerAnnotation("npm") + { + CommandLineArgs = ["run"] + }); + + // Only install packages during development, not in publish mode + if (!resource.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + var installerName = $"{resource.Resource.Name}-npm-install"; + var installer = new NpmInstallerResource(installerName, resource.Resource.WorkingDirectory); + + var installerBuilder = resource.ApplicationBuilder.AddResource(installer) + .WithArgs([useCI ? "ci" : "install"]) + .WithParentRelationship(resource.Resource) + .ExcludeFromManifest(); + + // Make the parent resource wait for the installer to complete + resource.WaitForCompletion(installerBuilder); + + configureInstaller?.Invoke(installerBuilder); + + resource.WithAnnotation(new JavaScriptPackageInstallerAnnotation(installer)); + } + + return resource; + } } diff --git a/src/Aspire.Hosting.NodeJs/NpmInstallerResource.cs b/src/Aspire.Hosting.NodeJs/NpmInstallerResource.cs new file mode 100644 index 00000000000..01943043102 --- /dev/null +++ b/src/Aspire.Hosting.NodeJs/NpmInstallerResource.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.NodeJs; + +/// +/// A resource that represents an npm package installer. +/// +/// The name of the resource. +/// The working directory to use for the command. +public class NpmInstallerResource(string name, string workingDirectory) + : ExecutableResource(name, "npm", workingDirectory); \ No newline at end of file diff --git a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs b/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs index 3d92e979c79..4824a97aa32 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs +++ b/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs @@ -4,7 +4,6 @@ #if UseRedisCache #:package Aspire.Hosting.Redis@!!REPLACE_WITH_LATEST_VERSION!! #endif -#:package CommunityToolkit.Aspire.Hosting.NodeJS.Extensions@9.8.0 #pragma warning disable ASPIREHOSTINGPYTHON001 @@ -29,7 +28,7 @@ }); builder.AddViteApp("frontend", "./frontend") - .WithNpmPackageInstallation() + .WithNpmPackageManager() .WithReference(apiService) .WaitFor(apiService); From 7fe9800f6b5b1b3cd82186fcbb4cf8e2b4b4eb96 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 22 Oct 2025 19:12:35 -0500 Subject: [PATCH 02/11] Create a ViteAppResource class Implement default dockerfile builder. Code clean up --- .../AspireJavaScript.AppHost/AppHost.cs | 3 +- .../aspire-manifest.json | 55 ++++++++++-- .../reactvite.Dockerfile | 5 ++ .../AspireJavaScript.Vite/Dockerfile | 23 ----- .../JavaScriptPackageManagerAnnotation.cs | 21 ++++- src/Aspire.Hosting.NodeJs/NodeExtensions.cs | 90 ++++++++++--------- src/Aspire.Hosting.NodeJs/ViteAppResource.cs | 13 +++ 7 files changed, 132 insertions(+), 78 deletions(-) create mode 100644 playground/AspireWithJavaScript/AspireJavaScript.AppHost/reactvite.Dockerfile delete mode 100644 playground/AspireWithJavaScript/AspireJavaScript.Vite/Dockerfile create mode 100644 src/Aspire.Hosting.NodeJs/ViteAppResource.cs diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs index e26811a5ec1..baf44dac935 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs +++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs @@ -29,7 +29,6 @@ .WithNpmPackageManager() .WithReference(weatherApi) .WithEnvironment("BROWSER", "none") - .WithExternalHttpEndpoints() - .PublishAsDockerFile(); + .WithExternalHttpEndpoints(); builder.Build().Run(); diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/aspire-manifest.json b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/aspire-manifest.json index 4b9f100e483..8e2b284d216 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/aspire-manifest.json +++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/aspire-manifest.json @@ -27,12 +27,16 @@ } }, "angular": { - "type": "dockerfile.v0", - "path": "../AspireJavaScript.Angular/Dockerfile", - "context": "../AspireJavaScript.Angular", + "type": "container.v1", + "build": { + "context": "../AspireJavaScript.Angular", + "dockerfile": "../AspireJavaScript.Angular/Dockerfile" + }, "env": { "NODE_ENV": "development", + "WEATHERAPI_HTTP": "{weatherapi.bindings.http.url}", "services__weatherapi__http__0": "{weatherapi.bindings.http.url}", + "WEATHERAPI_HTTPS": "{weatherapi.bindings.https.url}", "services__weatherapi__https__0": "{weatherapi.bindings.https.url}", "PORT": "{angular.bindings.http.targetPort}" }, @@ -47,12 +51,16 @@ } }, "react": { - "type": "dockerfile.v0", - "path": "../AspireJavaScript.React/Dockerfile", - "context": "../AspireJavaScript.React", + "type": "container.v1", + "build": { + "context": "../AspireJavaScript.React", + "dockerfile": "../AspireJavaScript.React/Dockerfile" + }, "env": { "NODE_ENV": "development", + "WEATHERAPI_HTTP": "{weatherapi.bindings.http.url}", "services__weatherapi__http__0": "{weatherapi.bindings.http.url}", + "WEATHERAPI_HTTPS": "{weatherapi.bindings.https.url}", "services__weatherapi__https__0": "{weatherapi.bindings.https.url}", "BROWSER": "none", "PORT": "{react.bindings.http.targetPort}" @@ -68,12 +76,16 @@ } }, "vue": { - "type": "dockerfile.v0", - "path": "../AspireJavaScript.Vue/Dockerfile", - "context": "../AspireJavaScript.Vue", + "type": "container.v1", + "build": { + "context": "../AspireJavaScript.Vue", + "dockerfile": "../AspireJavaScript.Vue/Dockerfile" + }, "env": { "NODE_ENV": "development", + "WEATHERAPI_HTTP": "{weatherapi.bindings.http.url}", "services__weatherapi__http__0": "{weatherapi.bindings.http.url}", + "WEATHERAPI_HTTPS": "{weatherapi.bindings.https.url}", "services__weatherapi__https__0": "{weatherapi.bindings.https.url}", "PORT": "{vue.bindings.http.targetPort}" }, @@ -86,6 +98,31 @@ "external": true } } + }, + "reactvite": { + "type": "container.v1", + "build": { + "context": "../AspireJavaScript.Vite", + "dockerfile": "reactvite.Dockerfile" + }, + "env": { + "NODE_ENV": "development", + "PORT": "{reactvite.bindings.http.targetPort}", + "WEATHERAPI_HTTP": "{weatherapi.bindings.http.url}", + "services__weatherapi__http__0": "{weatherapi.bindings.http.url}", + "WEATHERAPI_HTTPS": "{weatherapi.bindings.https.url}", + "services__weatherapi__https__0": "{weatherapi.bindings.https.url}", + "BROWSER": "none" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "targetPort": 8003, + "external": true + } + } } } } \ No newline at end of file diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/reactvite.Dockerfile b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/reactvite.Dockerfile new file mode 100644 index 00000000000..3bafae638df --- /dev/null +++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/reactvite.Dockerfile @@ -0,0 +1,5 @@ +FROM node:22-slim +WORKDIR /app +COPY . . +RUN npm install +RUN npm run build diff --git a/playground/AspireWithJavaScript/AspireJavaScript.Vite/Dockerfile b/playground/AspireWithJavaScript/AspireJavaScript.Vite/Dockerfile deleted file mode 100644 index 2ac3df971b3..00000000000 --- a/playground/AspireWithJavaScript/AspireJavaScript.Vite/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -# DisableDockerDetector "Playground/demo application used for testing Aspire features" -FROM node:20 as build - -WORKDIR /app - -COPY package.json package.json -COPY package-lock.json package-lock.json - -RUN npm install - -COPY . . - -RUN npm run build - -FROM nginx:alpine - -COPY --from=build /app/default.conf.template /etc/nginx/templates/default.conf.template -COPY --from=build /app/dist /usr/share/nginx/html - -# Expose the default nginx port -EXPOSE 80 - -CMD ["nginx", "-g", "daemon off;"] diff --git a/src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs b/src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs index 038d7cb6ab1..12d2fae0fa6 100644 --- a/src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs +++ b/src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs @@ -17,8 +17,23 @@ public sealed class JavaScriptPackageManagerAnnotation(string packageManager) : public string PackageManager { get; } = packageManager; /// - /// Gets the command line arguments for the JavaScript package manager. + /// Gets the command line arguments for the JavaScript package manager's install command. /// - public string[] CommandLineArgs { get; init; } = []; + public string[] InstallCommandLineArgs { get; init; } = []; -} \ No newline at end of file + /// + /// Gets the command line arguments for the JavaScript package manager's run command. + /// + public string[] RunCommandLineArgs { get; init; } = []; + + /// + /// Gets a string value that separates the package manager command line args from the tool's command line args. + /// By default, this is "--". + /// + public string? CommandSeparator { get; init; } = "--"; + + /// + /// Gets the command line arguments for the JavaScript package manager's command that produces assets for distribution. + /// + public string[] BuildCommandLineArgs { get; init; } = []; +} diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs index d69b4528905..d24e4db67f3 100644 --- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs +++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs @@ -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. +#pragma warning disable ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.NodeJs; using Aspire.Hosting.Utils; @@ -71,7 +73,7 @@ public static IResourceBuilder AddNpmApp(this IDistributedAppli .WithIconName("CodeJsRectangle"); } - private static IResourceBuilder WithNodeDefaults(this IResourceBuilder builder) => + private static IResourceBuilder WithNodeDefaults(this IResourceBuilder builder) where TResource : NodeAppResource => builder.WithOtlpExporter() .WithEnvironment("NODE_ENV", builder.ApplicationBuilder.Environment.IsDevelopment() ? "development" : "production") .WithExecutableCertificateTrustCallback((ctx) => @@ -98,82 +100,88 @@ private static IResourceBuilder WithNodeDefaults(this IResource /// When true use HTTPS for the endpoints, otherwise use HTTP. /// A reference to the . /// This uses the specified package manager (default npm) method internally but sets defaults that would be expected to run a Vite app, such as the command to run the dev server and exposing the HTTP endpoints. - public static IResourceBuilder AddViteApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string? workingDirectory = null, bool useHttps = false) + public static IResourceBuilder AddViteApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string? workingDirectory = null, bool useHttps = false) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); ArgumentException.ThrowIfNullOrEmpty(workingDirectory); workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory)); - var resource = new NodeAppResource(name, "node", workingDirectory); + var resource = new ViteAppResource(name, "node", workingDirectory); var resourceBuilder = builder.AddResource(resource) .WithNodeDefaults() + .WithIconName("CodeJsRectangle") .WithArgs(c => { if (c.Resource.TryGetLastAnnotation(out var packageManagerAnnotation)) { - foreach (var arg in packageManagerAnnotation.CommandLineArgs) + foreach (var arg in packageManagerAnnotation.RunCommandLineArgs) { c.Args.Add(arg); } } c.Args.Add("dev"); - }) - .WithIconName("CodeJsRectangle"); - _ = useHttps - ? resourceBuilder.WithHttpsEndpoint(env: "PORT") - : resourceBuilder.WithHttpEndpoint(env: "PORT"); - - resourceBuilder.WithMappedEndpointPort(); + if (packageManagerAnnotation?.CommandSeparator is string separator) + { + c.Args.Add(separator); + } - return resourceBuilder; - } + var targetEndpoint = resource.GetEndpoint("https"); + if (!targetEndpoint.Exists) + { + targetEndpoint = resource.GetEndpoint("http"); + } - /// - /// Maps the endpoint port for the to the appropriate command line argument. - /// - /// The Node.js app resource. - /// The name of the endpoint to map. If not specified, it will use the first HTTP or HTTPS endpoint found. - /// A reference to the . - public static IResourceBuilder WithMappedEndpointPort(this IResourceBuilder builder, string? endpointName = null) where TResource : NodeAppResource - { - ArgumentNullException.ThrowIfNull(builder); + c.Args.Add("--port"); + c.Args.Add(targetEndpoint.Property(EndpointProperty.TargetPort)); + }); - return builder.WithArgs(ctx => - { - var resource = builder.Resource; + _ = useHttps + ? resourceBuilder.WithHttpsEndpoint(env: "PORT") + : resourceBuilder.WithHttpEndpoint(env: "PORT"); - // monorepo tools will need `--`, as does npm - if (!resource.TryGetLastAnnotation(out var packageManagerAnnotation) || packageManagerAnnotation.PackageManager == "npm") + return resourceBuilder + .PublishAsDockerFile(c => { - ctx.Args.Add("--"); - } - - // Find the target endpoint by name, or default to http/https if no name specified - var targetEndpoint = endpointName is not null - ? resource.GetEndpoint(endpointName) - : resource.GetEndpoints().FirstOrDefault(e => e.EndpointName == "https") ?? resource.GetEndpoint("http"); - - ctx.Args.Add("--port"); - ctx.Args.Add(targetEndpoint.Property(EndpointProperty.TargetPort)); - }); + c.WithDockerfileBuilder(workingDirectory, dockerfileContext => + { + if (c.Resource.TryGetLastAnnotation(out var packageManagerAnnotation) + && packageManagerAnnotation.BuildCommandLineArgs is { Length: > 0 }) + { + var dockerBuilder = dockerfileContext.Builder + .From("node:22-slim") + .WorkDir("/app") + .Copy(".", "."); + + if (packageManagerAnnotation.InstallCommandLineArgs is { Length: > 0 }) + { + dockerBuilder + .Run($"{resourceBuilder.Resource.Command} {string.Join(' ', packageManagerAnnotation.InstallCommandLineArgs)}"); + } + dockerBuilder + .Run($"{resourceBuilder.Resource.Command} {string.Join(' ', packageManagerAnnotation.BuildCommandLineArgs)}"); + } + }); + }); } /// /// Ensures the Node.js packages are installed before the application starts using npm as the package manager. /// - /// The Node.js app resource. + /// The NodeAppResource. /// When true use npm ci otherwise use npm install when installing packages. /// Configure the npm installer resource. /// A reference to the . - public static IResourceBuilder WithNpmPackageManager(this IResourceBuilder resource, bool useCI = false, Action>? configureInstaller = null) + public static IResourceBuilder WithNpmPackageManager(this IResourceBuilder resource, bool useCI = false, Action>? configureInstaller = null) where TResource : NodeAppResource { resource.WithCommand("npm"); resource.WithAnnotation(new JavaScriptPackageManagerAnnotation("npm") { - CommandLineArgs = ["run"] + InstallCommandLineArgs = [useCI ? "ci" : "install"], + RunCommandLineArgs = ["run"], + BuildCommandLineArgs = ["run", "build"] }); // Only install packages during development, not in publish mode diff --git a/src/Aspire.Hosting.NodeJs/ViteAppResource.cs b/src/Aspire.Hosting.NodeJs/ViteAppResource.cs new file mode 100644 index 00000000000..6ca2b1690dc --- /dev/null +++ b/src/Aspire.Hosting.NodeJs/ViteAppResource.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.NodeJs; + +/// +/// Represents a Vite application resource that can be managed and executed within a Node.js environment. +/// +/// The unique name used to identify the Vite application resource. +/// The command to execute the Vite application, such as the script or entry point. +/// The working directory from which the Vite application command is executed. +public class ViteAppResource(string name, string command, string workingDirectory) + : NodeAppResource(name, command, workingDirectory); From 167c5bff0d36c1924d1f35889a9ea4381428b5d4 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 22 Oct 2025 19:32:01 -0500 Subject: [PATCH 03/11] Port unit tests from CommunityToolkit. --- src/Aspire.Hosting.NodeJs/NodeExtensions.cs | 19 +- .../IntegrationTests.cs | 81 +++++++ .../PackageInstallationTests.cs | 121 ++++++++++ .../ResourceCreationTests.cs | 212 ++++++++++++++++++ 4 files changed, 430 insertions(+), 3 deletions(-) create mode 100644 tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs create mode 100644 tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs create mode 100644 tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs index d24e4db67f3..7912654c8d0 100644 --- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs +++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs @@ -99,8 +99,21 @@ private static IResourceBuilder WithNodeDefaults(this IRes /// The working directory of the Vite app. /// When true use HTTPS for the endpoints, otherwise use HTTP. /// A reference to the . - /// This uses the specified package manager (default npm) method internally but sets defaults that would be expected to run a Vite app, such as the command to run the dev server and exposing the HTTP endpoints. - public static IResourceBuilder AddViteApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string? workingDirectory = null, bool useHttps = false) + /// + /// + /// + /// The following example creates a Vite app using npm as the package manager. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// builder.AddViteApp("frontend", "./frontend") + /// .WithNpmPackageManager(); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder AddViteApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, bool useHttps = false) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); @@ -114,7 +127,7 @@ public static IResourceBuilder AddViteApp(this IDistributedAppl .WithIconName("CodeJsRectangle") .WithArgs(c => { - if (c.Resource.TryGetLastAnnotation(out var packageManagerAnnotation)) + if (resource.TryGetLastAnnotation(out var packageManagerAnnotation)) { foreach (var arg in packageManagerAnnotation.RunCommandLineArgs) { diff --git a/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs new file mode 100644 index 00000000000..23c735377fe --- /dev/null +++ b/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.NodeJs.Tests; + +/// +/// Integration test that demonstrates the new resource-based package installer architecture. +/// This shows how installer resources appear as separate resources in the application model. +/// +public class IntegrationTests +{ + [Fact] + public void ResourceBasedPackageInstallersAppearInApplicationModel() + { + var builder = DistributedApplication.CreateBuilder(); + + // Add a vite apps with the npm package manager + builder.AddViteApp("vite-app", "./frontend") + .WithNpmPackageManager(useCI: true); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify all Node.js app resources are present + var nodeResources = appModel.Resources.OfType().ToList(); + Assert.Single(nodeResources); + + // Verify all installer resources are present as separate resources + var npmInstallers = appModel.Resources.OfType().ToList(); + + Assert.Single(npmInstallers); + + // Verify installer resources have expected names (would appear on dashboard) + Assert.Equal("vite-app-npm-install", npmInstallers[0].Name); + + // Verify parent-child relationships + foreach (var installer in npmInstallers.Cast()) + { + Assert.True(installer.TryGetAnnotationsOfType(out var relationships)); + Assert.Single(relationships); + Assert.Equal("Parent", relationships.First().Type); + } + + // Verify all Node.js apps wait for their installers + foreach (var nodeApp in nodeResources) + { + Assert.True(nodeApp.TryGetAnnotationsOfType(out var waitAnnotations)); + Assert.Single(waitAnnotations); + + var waitedResource = waitAnnotations.First().Resource; + Assert.True(waitedResource is NpmInstallerResource); + } + } + + [Fact] + public void InstallerResourcesHaveCorrectExecutableConfiguration() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddNpmApp("test-app", "./test") + .WithNpmPackageManager(useCI: true); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var installer = Assert.Single(appModel.Resources.OfType()); + + // Verify it's configured as an ExecutableResource + Assert.IsAssignableFrom(installer); + + // Verify working directory matches parent + var parentApp = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(parentApp.WorkingDirectory, installer.WorkingDirectory); + + // Verify command arguments are configured + Assert.True(installer.TryGetAnnotationsOfType(out var argsAnnotations)); + } +} diff --git a/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs new file mode 100644 index 00000000000..d389a0329c9 --- /dev/null +++ b/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.NodeJs.Tests; + +public class PackageInstallationTests +{ + /// + /// This test validates that the WithNpmPackageManager method creates + /// installer resources with proper arguments and relationships. + /// + [Fact] + public async Task WithNpmPackageManager_CanBeConfiguredWithInstallAndCIOptions() + { + var builder = DistributedApplication.CreateBuilder(); + + var nodeApp = builder.AddNpmApp("test-app", "./test-app"); + var nodeApp2 = builder.AddNpmApp("test-app-ci", "./test-app-ci"); + + // Test that both configurations can be set up without errors + nodeApp.WithNpmPackageManager(useCI: false); // Uses npm install + nodeApp2.WithNpmPackageManager(useCI: true); // Uses npm ci + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var nodeResources = appModel.Resources.OfType().ToList(); + var installerResources = appModel.Resources.OfType().ToList(); + + Assert.Equal(2, nodeResources.Count); + Assert.Equal(2, installerResources.Count); + Assert.All(nodeResources, resource => Assert.Equal("npm", resource.Command)); + + // Verify install vs ci commands + var installResource = installerResources.Single(r => r.Name == "test-app-npm-install"); + var ciResource = installerResources.Single(r => r.Name == "test-app-ci-npm-install"); + + Assert.Equal("npm", installResource.Command); + var args = await installResource.GetArgumentValuesAsync(); + Assert.Single(args); + Assert.Equal("install", args[0]); + + Assert.Equal("npm", ciResource.Command); + args = await ciResource.GetArgumentValuesAsync(); + Assert.Single(args); + Assert.Equal("ci", args[0]); + } + + [Fact] + public void WithNpmPackageManager_ExcludedFromPublishMode() + { + var builder = DistributedApplication.CreateBuilder(["Publishing:Publisher=manifest", "Publishing:OutputPath=./publish"]); + + var nodeApp = builder.AddNpmApp("test-app", "./test-app"); + nodeApp.WithNpmPackageManager(useCI: false); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + // Verify the NodeApp resource exists + var nodeResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("npm", nodeResource.Command); + + // Verify NO installer resource was created in publish mode + var installerResources = appModel.Resources.OfType().ToList(); + Assert.Empty(installerResources); + + // Verify no wait annotations were added + Assert.False(nodeResource.TryGetAnnotationsOfType(out _)); + } + + [Fact] + public async Task WithNpmPackageManager_CanAcceptAdditionalArgs() + { + var builder = DistributedApplication.CreateBuilder(); + + var nodeApp = builder.AddNpmApp("test-app", "./test-app"); + var nodeAppWithArgs = builder.AddNpmApp("test-app-args", "./test-app-args"); + + // Test npm install with additional args + nodeApp.WithNpmPackageManager(useCI: false, configureInstaller: installerBuilder => + { + installerBuilder.WithArgs("--legacy-peer-deps"); + }); + nodeAppWithArgs.WithNpmPackageManager(useCI: true, configureInstaller: installerBuilder => + { + installerBuilder.WithArgs("--verbose", "--no-optional"); + }); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var installerResources = appModel.Resources.OfType().ToList(); + + Assert.Equal(2, installerResources.Count); + + var installResource = installerResources.Single(r => r.Name == "test-app-npm-install"); + var ciResource = installerResources.Single(r => r.Name == "test-app-args-npm-install"); + + // Verify install command with additional args + var installArgs = await installResource.GetArgumentValuesAsync(); + Assert.Collection( + installArgs, + arg => Assert.Equal("install", arg), + arg => Assert.Equal("--legacy-peer-deps", arg) + ); + + // Verify ci command with additional args + var ciArgs = await ciResource.GetArgumentValuesAsync(); + Assert.Collection( + ciArgs, + arg => Assert.Equal("ci", arg), + arg => Assert.Equal("--verbose", arg), + arg => Assert.Equal("--no-optional", arg) + ); + } +} diff --git a/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs new file mode 100644 index 00000000000..8657b61e6b0 --- /dev/null +++ b/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs @@ -0,0 +1,212 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.NodeJs.Tests; + +public class ResourceCreationTests +{ + [Fact] + public void DefaultViteAppUsesNode() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddViteApp("vite", "vite"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = appModel.Resources.OfType().SingleOrDefault(); + + Assert.NotNull(resource); + + Assert.Equal("node", resource.Command); + } + + [Fact] + public void ViteAppUsesSpecifiedWorkingDirectory() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddViteApp("vite", "test"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = appModel.Resources.OfType().SingleOrDefault(); + + Assert.NotNull(resource); + + Assert.Equal(Path.Combine(builder.AppHostDirectory, "test"), resource.WorkingDirectory); + } + + [Fact] + public void ViteAppHasExposedHttpEndpoints() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddViteApp("vite", "vite"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = appModel.Resources.OfType().SingleOrDefault(); + + Assert.NotNull(resource); + + Assert.True(resource.TryGetAnnotationsOfType(out var endpoints)); + + Assert.Contains(endpoints, e => e.UriScheme == "http"); + } + + [Fact] + public void ViteAppHasExposedHttpsEndpoints() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddViteApp("vite", "vite", useHttps: true); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = appModel.Resources.OfType().SingleOrDefault(); + + Assert.NotNull(resource); + + Assert.True(resource.TryGetAnnotationsOfType(out var endpoints)); + + Assert.Contains(endpoints, e => e.UriScheme == "https"); + } + + [Fact] + public void ViteAppDoesNotExposeExternalHttpEndpointsByDefault() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddViteApp("vite", "vite"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = appModel.Resources.OfType().SingleOrDefault(); + + Assert.NotNull(resource); + + Assert.True(resource.TryGetAnnotationsOfType(out var endpoints)); + + Assert.DoesNotContain(endpoints, e => e.IsExternal); + } + + [Fact] + public async Task WithNpmPackageManagerDefaultsToInstallCommand() + { + var builder = DistributedApplication.CreateBuilder(); + + var nodeApp = builder.AddNpmApp("test-app", "./test-app"); + + // Add package installation with default settings (should use npm install, not ci) + nodeApp.WithNpmPackageManager(useCI: false); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + // Verify the NodeApp resource exists + var nodeResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("npm", nodeResource.Command); + + // Verify the installer resource was created + var installerResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("test-app-npm-install", installerResource.Name); + Assert.Equal("npm", installerResource.Command); + var args = await installerResource.GetArgumentValuesAsync(); + Assert.Single(args); + Assert.Equal("install", args[0]); + + // Verify the parent-child relationship + Assert.True(installerResource.TryGetAnnotationsOfType(out var relationships)); + var relationship = Assert.Single(relationships); + Assert.Same(nodeResource, relationship.Resource); + Assert.Equal("Parent", relationship.Type); + + // Verify the wait annotation on the parent + Assert.True(nodeResource.TryGetAnnotationsOfType(out var waitAnnotations)); + var waitAnnotation = Assert.Single(waitAnnotations); + Assert.Same(installerResource, waitAnnotation.Resource); + } + + [Fact] + public async Task WithNpmPackageManagerCanUseCICommand() + { + var builder = DistributedApplication.CreateBuilder(); + + var nodeApp = builder.AddNpmApp("test-app", "./test-app"); + + // Add package installation with CI enabled + nodeApp.WithNpmPackageManager(useCI: true); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + // Verify the NodeApp resource exists + var nodeResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("npm", nodeResource.Command); + + // Verify the installer resource was created with CI enabled + var installerResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("test-app-npm-install", installerResource.Name); + Assert.Equal("npm", installerResource.Command); + var args = await installerResource.GetArgumentValuesAsync(); + Assert.Single(args); + Assert.Equal("ci", args[0]); + + // Verify the parent-child relationship + Assert.True(installerResource.TryGetAnnotationsOfType(out var relationships)); + var relationship = Assert.Single(relationships); + Assert.Same(nodeResource, relationship.Resource); + Assert.Equal("Parent", relationship.Type); + + // Verify the wait annotation on the parent + Assert.True(nodeResource.TryGetAnnotationsOfType(out var waitAnnotations)); + var waitAnnotation = Assert.Single(waitAnnotations); + Assert.Same(installerResource, waitAnnotation.Resource); + } + + [Fact] + public void ViteAppConfiguresPortFromEnvironment() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddViteApp("vite", "vite"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + // Verify that command line arguments callback is configured + Assert.True(resource.TryGetAnnotationsOfType(out var argsCallbackAnnotations)); + List args = []; + var ctx = new CommandLineArgsCallbackContext(args); + + foreach (var annotation in argsCallbackAnnotations) + { + annotation.Callback(ctx); + } + + Assert.Collection(args, + arg => Assert.Equal("dev", arg), + arg => Assert.Equal("--port", arg), + arg => Assert.IsType(arg) + ); + } +} From 87288a47c011e17295585f8d1fe5e8a04f9f5743 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 22 Oct 2025 19:39:15 -0500 Subject: [PATCH 04/11] Add dockerfile test. --- .../AddViteAppTests.cs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs diff --git a/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs new file mode 100644 index 00000000000..155f723b657 --- /dev/null +++ b/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.NodeJs.Tests; + +public class AddViteAppTests +{ + [Fact] + public async Task VerifyDefaultDockerfile() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish).WithResourceCleanUp(true); + + var workingDirectory = AppContext.BaseDirectory; + var nodeApp = builder.AddViteApp("vite", "vite") + .WithNpmPackageManager(); + + var manifest = await ManifestUtils.GetManifest(nodeApp.Resource); + + var expectedManifest = $$""" + { + "type": "container.v1", + "build": { + "context": "../../../../../tests/Aspire.Hosting.Tests/vite", + "dockerfile": "vite.Dockerfile" + }, + "env": { + "NODE_ENV": "production", + "PORT": "{vite.bindings.http.targetPort}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "targetPort": 8000 + } + } + } + """; + Assert.Equal(expectedManifest, manifest.ToString()); + + var dockerfileContents = File.ReadAllText("vite.Dockerfile"); + var expectedDockerfile = $$""" + FROM node:22-slim + WORKDIR /app + COPY . . + RUN npm install + RUN npm run build + + """.Replace("\r\n", "\n"); + Assert.Equal(expectedDockerfile, dockerfileContents); + } +} From 5cdddfdf0decc9e97f36c2c956bb61f2b4bbf909 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 22 Oct 2025 19:41:42 -0500 Subject: [PATCH 05/11] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.NodeJs/NodeExtensions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs index 7912654c8d0..7620795ee65 100644 --- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs +++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs @@ -100,7 +100,6 @@ private static IResourceBuilder WithNodeDefaults(this IRes /// When true use HTTPS for the endpoints, otherwise use HTTP. /// A reference to the . /// - /// /// /// The following example creates a Vite app using npm as the package manager. /// From 083ef660ce91dbd7f96f64fae979a9d663041ec7 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 22 Oct 2025 19:41:58 -0500 Subject: [PATCH 06/11] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.NodeJs/NodeExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs index 7620795ee65..ba31bbbf8c2 100644 --- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs +++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs @@ -183,7 +183,7 @@ public static IResourceBuilder AddViteApp(this IDistributedAppl /// Ensures the Node.js packages are installed before the application starts using npm as the package manager. /// /// The NodeAppResource. - /// When true use npm ci otherwise use npm install when installing packages. + /// When true, use npm ci, otherwise use npm install when installing packages. /// Configure the npm installer resource. /// A reference to the . public static IResourceBuilder WithNpmPackageManager(this IResourceBuilder resource, bool useCI = false, Action>? configureInstaller = null) where TResource : NodeAppResource From 95e9dc2c8f7eabacf98be60c6d4f8993d182770d Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 22 Oct 2025 19:42:07 -0500 Subject: [PATCH 07/11] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs index 23c735377fe..5e082691c40 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs @@ -17,7 +17,7 @@ public void ResourceBasedPackageInstallersAppearInApplicationModel() { var builder = DistributedApplication.CreateBuilder(); - // Add a vite apps with the npm package manager + // Add a vite app with the npm package manager builder.AddViteApp("vite-app", "./frontend") .WithNpmPackageManager(useCI: true); From f4528a655dc2ddd2f95a31a96fc81d2efc7d5c86 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 22 Oct 2025 22:00:04 -0500 Subject: [PATCH 08/11] Rename NpmInstallerResource to NodeInstallerResource. --- src/Aspire.Hosting.NodeJs/NodeExtensions.cs | 5 +++-- .../{NpmInstallerResource.cs => NodeInstallerResource.cs} | 6 +++--- tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs | 6 +++--- .../Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs | 6 +++--- tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs | 4 ++-- 5 files changed, 14 insertions(+), 13 deletions(-) rename src/Aspire.Hosting.NodeJs/{NpmInstallerResource.cs => NodeInstallerResource.cs} (66%) diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs index ba31bbbf8c2..d2468f24484 100644 --- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs +++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs @@ -186,7 +186,7 @@ public static IResourceBuilder AddViteApp(this IDistributedAppl /// When true, use npm ci, otherwise use npm install when installing packages. /// Configure the npm installer resource. /// A reference to the . - public static IResourceBuilder WithNpmPackageManager(this IResourceBuilder resource, bool useCI = false, Action>? configureInstaller = null) where TResource : NodeAppResource + public static IResourceBuilder WithNpmPackageManager(this IResourceBuilder resource, bool useCI = false, Action>? configureInstaller = null) where TResource : NodeAppResource { resource.WithCommand("npm"); resource.WithAnnotation(new JavaScriptPackageManagerAnnotation("npm") @@ -200,9 +200,10 @@ public static IResourceBuilder WithNpmPackageManager(this if (!resource.ApplicationBuilder.ExecutionContext.IsPublishMode) { var installerName = $"{resource.Resource.Name}-npm-install"; - var installer = new NpmInstallerResource(installerName, resource.Resource.WorkingDirectory); + var installer = new NodeInstallerResource(installerName, resource.Resource.WorkingDirectory); var installerBuilder = resource.ApplicationBuilder.AddResource(installer) + .WithCommand("npm") .WithArgs([useCI ? "ci" : "install"]) .WithParentRelationship(resource.Resource) .ExcludeFromManifest(); diff --git a/src/Aspire.Hosting.NodeJs/NpmInstallerResource.cs b/src/Aspire.Hosting.NodeJs/NodeInstallerResource.cs similarity index 66% rename from src/Aspire.Hosting.NodeJs/NpmInstallerResource.cs rename to src/Aspire.Hosting.NodeJs/NodeInstallerResource.cs index 01943043102..c55bb5a6432 100644 --- a/src/Aspire.Hosting.NodeJs/NpmInstallerResource.cs +++ b/src/Aspire.Hosting.NodeJs/NodeInstallerResource.cs @@ -6,9 +6,9 @@ namespace Aspire.Hosting.NodeJs; /// -/// A resource that represents an npm package installer. +/// A resource that represents a package installer for a node app. /// /// The name of the resource. /// The working directory to use for the command. -public class NpmInstallerResource(string name, string workingDirectory) - : ExecutableResource(name, "npm", workingDirectory); \ No newline at end of file +public class NodeInstallerResource(string name, string workingDirectory) + : ExecutableResource(name, "node", workingDirectory); diff --git a/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs index 5e082691c40..4beae9b1200 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs @@ -29,7 +29,7 @@ public void ResourceBasedPackageInstallersAppearInApplicationModel() Assert.Single(nodeResources); // Verify all installer resources are present as separate resources - var npmInstallers = appModel.Resources.OfType().ToList(); + var npmInstallers = appModel.Resources.OfType().ToList(); Assert.Single(npmInstallers); @@ -51,7 +51,7 @@ public void ResourceBasedPackageInstallersAppearInApplicationModel() Assert.Single(waitAnnotations); var waitedResource = waitAnnotations.First().Resource; - Assert.True(waitedResource is NpmInstallerResource); + Assert.True(waitedResource is NodeInstallerResource); } } @@ -66,7 +66,7 @@ public void InstallerResourcesHaveCorrectExecutableConfiguration() using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var installer = Assert.Single(appModel.Resources.OfType()); + var installer = Assert.Single(appModel.Resources.OfType()); // Verify it's configured as an ExecutableResource Assert.IsAssignableFrom(installer); diff --git a/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs index d389a0329c9..5bf8abd794d 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs @@ -28,7 +28,7 @@ public async Task WithNpmPackageManager_CanBeConfiguredWithInstallAndCIOptions() var appModel = app.Services.GetRequiredService(); var nodeResources = appModel.Resources.OfType().ToList(); - var installerResources = appModel.Resources.OfType().ToList(); + var installerResources = appModel.Resources.OfType().ToList(); Assert.Equal(2, nodeResources.Count); Assert.Equal(2, installerResources.Count); @@ -66,7 +66,7 @@ public void WithNpmPackageManager_ExcludedFromPublishMode() Assert.Equal("npm", nodeResource.Command); // Verify NO installer resource was created in publish mode - var installerResources = appModel.Resources.OfType().ToList(); + var installerResources = appModel.Resources.OfType().ToList(); Assert.Empty(installerResources); // Verify no wait annotations were added @@ -94,7 +94,7 @@ public async Task WithNpmPackageManager_CanAcceptAdditionalArgs() using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var installerResources = appModel.Resources.OfType().ToList(); + var installerResources = appModel.Resources.OfType().ToList(); Assert.Equal(2, installerResources.Count); diff --git a/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs index 8657b61e6b0..71504e1bf4f 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs @@ -123,7 +123,7 @@ public async Task WithNpmPackageManagerDefaultsToInstallCommand() Assert.Equal("npm", nodeResource.Command); // Verify the installer resource was created - var installerResource = Assert.Single(appModel.Resources.OfType()); + var installerResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("test-app-npm-install", installerResource.Name); Assert.Equal("npm", installerResource.Command); var args = await installerResource.GetArgumentValuesAsync(); @@ -161,7 +161,7 @@ public async Task WithNpmPackageManagerCanUseCICommand() Assert.Equal("npm", nodeResource.Command); // Verify the installer resource was created with CI enabled - var installerResource = Assert.Single(appModel.Resources.OfType()); + var installerResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("test-app-npm-install", installerResource.Name); Assert.Equal("npm", installerResource.Command); var args = await installerResource.GetArgumentValuesAsync(); From 6517db17a6638285ad00db07a3a9fa0aae0c9df2 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 22 Oct 2025 22:12:13 -0500 Subject: [PATCH 09/11] Remove npm install from csproj and use NpmPackageManager in the playground app. --- .../AspireJavaScript.AppHost/AppHost.cs | 3 +++ .../AspireJavaScript.AppHost.csproj | 18 ------------------ 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs index baf44dac935..15c89c54b9e 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs +++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs @@ -4,6 +4,7 @@ .WithExternalHttpEndpoints(); builder.AddNpmApp("angular", "../AspireJavaScript.Angular") + .WithNpmPackageManager() .WithReference(weatherApi) .WaitFor(weatherApi) .WithHttpEndpoint(env: "PORT") @@ -11,6 +12,7 @@ .PublishAsDockerFile(); builder.AddNpmApp("react", "../AspireJavaScript.React") + .WithNpmPackageManager() .WithReference(weatherApi) .WaitFor(weatherApi) .WithEnvironment("BROWSER", "none") // Disable opening browser on npm start @@ -19,6 +21,7 @@ .PublishAsDockerFile(); builder.AddNpmApp("vue", "../AspireJavaScript.Vue") + .WithNpmPackageManager() .WithReference(weatherApi) .WaitFor(weatherApi) .WithHttpEndpoint(env: "PORT") diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj index aef5911e35f..262a263f6d4 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj +++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj @@ -18,22 +18,4 @@ - - - - - - - - - - - - - - - - - - From dcf6b331bb254144bb84656fa5a60e6050cc08a0 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 22 Oct 2025 22:19:09 -0500 Subject: [PATCH 10/11] Check for an existing dockerfile. --- src/Aspire.Hosting.NodeJs/NodeExtensions.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs index d2468f24484..1cc755a97ea 100644 --- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs +++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs @@ -157,7 +157,13 @@ public static IResourceBuilder AddViteApp(this IDistributedAppl return resourceBuilder .PublishAsDockerFile(c => { - c.WithDockerfileBuilder(workingDirectory, dockerfileContext => + // Only generate a Dockerfile if one doesn't already exist in the app directory + if (File.Exists(Path.Combine(resource.WorkingDirectory, "Dockerfile"))) + { + return; + } + + c.WithDockerfileBuilder(resource.WorkingDirectory, dockerfileContext => { if (c.Resource.TryGetLastAnnotation(out var packageManagerAnnotation) && packageManagerAnnotation.BuildCommandLineArgs is { Length: > 0 }) From 7965516eb83caac4ad6918821b78d5f3d1435374 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 23 Oct 2025 08:08:54 -0500 Subject: [PATCH 11/11] Default to npm in ViteApp. --- src/Aspire.Hosting.NodeJs/NodeExtensions.cs | 22 +++++++++++++------ .../ResourceCreationTests.cs | 6 +++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs index 1cc755a97ea..dc133ade377 100644 --- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs +++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs @@ -155,6 +155,7 @@ public static IResourceBuilder AddViteApp(this IDistributedAppl : resourceBuilder.WithHttpEndpoint(env: "PORT"); return resourceBuilder + .AddNpmPackageManagerAnnotation(useCI: false) .PublishAsDockerFile(c => { // Only generate a Dockerfile if one doesn't already exist in the app directory @@ -194,13 +195,7 @@ public static IResourceBuilder AddViteApp(this IDistributedAppl /// A reference to the . public static IResourceBuilder WithNpmPackageManager(this IResourceBuilder resource, bool useCI = false, Action>? configureInstaller = null) where TResource : NodeAppResource { - resource.WithCommand("npm"); - resource.WithAnnotation(new JavaScriptPackageManagerAnnotation("npm") - { - InstallCommandLineArgs = [useCI ? "ci" : "install"], - RunCommandLineArgs = ["run"], - BuildCommandLineArgs = ["run", "build"] - }); + AddNpmPackageManagerAnnotation(resource, useCI); // Only install packages during development, not in publish mode if (!resource.ApplicationBuilder.ExecutionContext.IsPublishMode) @@ -224,4 +219,17 @@ public static IResourceBuilder WithNpmPackageManager(this return resource; } + + private static IResourceBuilder AddNpmPackageManagerAnnotation(this IResourceBuilder resource, bool useCI) where TResource : NodeAppResource + { + resource.WithCommand("npm"); + resource.WithAnnotation(new JavaScriptPackageManagerAnnotation("npm") + { + InstallCommandLineArgs = [useCI ? "ci" : "install"], + RunCommandLineArgs = ["run"], + BuildCommandLineArgs = ["run", "build"] + }); + + return resource; + } } diff --git a/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs index 71504e1bf4f..2d0da25f65d 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs @@ -9,7 +9,7 @@ namespace Aspire.Hosting.NodeJs.Tests; public class ResourceCreationTests { [Fact] - public void DefaultViteAppUsesNode() + public void DefaultViteAppUsesNpm() { var builder = DistributedApplication.CreateBuilder(); @@ -23,7 +23,7 @@ public void DefaultViteAppUsesNode() Assert.NotNull(resource); - Assert.Equal("node", resource.Command); + Assert.Equal("npm", resource.Command); } [Fact] @@ -204,7 +204,9 @@ public void ViteAppConfiguresPortFromEnvironment() } Assert.Collection(args, + arg => Assert.Equal("run", arg), arg => Assert.Equal("dev", arg), + arg => Assert.Equal("--", arg), arg => Assert.Equal("--port", arg), arg => Assert.IsType(arg) );