Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions Aspire.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@
<File Path="playground/README.md" />
<Project Path="playground/Playground.ServiceDefaults/Playground.ServiceDefaults.csproj" />
</Folder>
<Folder Name="/playground/AspireWithJavaScript/">
<Project Path="playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj" />
<Project Path="playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AspireJavaScript.MinimalApi.csproj" />
<Project Path="playground/AspireWithJavaScript/AspireJavaScript.ServiceDefaults/AspireJavaScript.ServiceDefaults.csproj" />
</Folder>
<Folder Name="/playground/AzureAIFoundryEndToEnd/">
<Project Path="playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.AppHost/AzureAIFoundryEndToEnd.AppHost.csproj" />
<Project Path="playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.WebStory/AzureAIFoundryEndToEnd.WebStory.csproj" />
Expand Down Expand Up @@ -176,8 +181,8 @@
<Project Path="playground/deployers/Deployers.AppHost/Deployers.AppHost.csproj" />
</Folder>
<Folder Name="/playground/DevTunnels/">
<Project Path="playground/DevTunnels/DevTunnels.AppHost/DevTunnels.AppHost.csproj" />
<Project Path="playground/DevTunnels/DevTunnels.ApiService/DevTunnels.ApiService.csproj" />
<Project Path="playground/DevTunnels/DevTunnels.AppHost/DevTunnels.AppHost.csproj" />
<Project Path="playground/DevTunnels/DevTunnels.WebFrontEnd/DevTunnels.WebFrontEnd.csproj" />
</Folder>
<Folder Name="/playground/dockerfile/">
Expand All @@ -199,11 +204,6 @@
<Folder Name="/playground/HealthChecks/">
<Project Path="playground/HealthChecks/HealthChecksSandbox.AppHost/HealthChecksSandbox.AppHost.csproj" />
</Folder>
<Folder Name="/playground/javascript/">
<Project Path="playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj" />
<Project Path="playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AspireJavaScript.MinimalApi.csproj" />
<Project Path="playground/AspireWithJavaScript/AspireJavaScript.ServiceDefaults/AspireJavaScript.ServiceDefaults.csproj" />
</Folder>
<Folder Name="/playground/kafka/">
<Project Path="playground/kafka/Consumer/Consumer.csproj" />
<Project Path="playground/kafka/KafkaBasic.AppHost/KafkaBasic.AppHost.csproj" />
Expand Down Expand Up @@ -234,8 +234,8 @@
</Folder>
<Folder Name="/playground/node/">
<Project Path="playground/AspireWithNode/AspireWithNode.AppHost/AspireWithNode.AppHost.csproj" />
<Project Path="playground/AspireWithNode/AspireWithNode.ServiceDefaults/AspireWithNode.ServiceDefaults.csproj" />
<Project Path="playground/AspireWithNode/AspireWithNode.AspNetCoreApi/AspireWithNode.AspNetCoreApi.csproj" />
<Project Path="playground/AspireWithNode/AspireWithNode.ServiceDefaults/AspireWithNode.ServiceDefaults.csproj" />
</Folder>
<Folder Name="/playground/OpenAIEndToEnd/">
<Project Path="playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/OpenAIEndToEnd.AppHost.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
var builder = DistributedApplication.CreateBuilder(args);
var builder = DistributedApplication.CreateBuilder(args);

var weatherApi = builder.AddProject<Projects.AspireJavaScript_MinimalApi>("weatherapi")
.WithExternalHttpEndpoints();
Expand All @@ -25,11 +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();
.WithExternalHttpEndpoints();

builder.Build().Run();
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
},
Expand All @@ -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}"
Expand All @@ -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}"
},
Expand All @@ -86,6 +98,31 @@
"external": true
}
}
},
"reactvite": {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vhvb1989 this is what wil break azd, we're not deploying this resource but need to build it.

"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
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
23 changes: 0 additions & 23 deletions playground/AspireWithJavaScript/AspireJavaScript.Vite/Dockerfile

This file was deleted.

17 changes: 17 additions & 0 deletions src/Aspire.Hosting.NodeJs/JavaScriptPackageInstallerAnnotation.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Represents an annotation for a JavaScript installer resource.
/// </summary>
public sealed class JavaScriptPackageInstallerAnnotation(ExecutableResource installerResource) : IResourceAnnotation
{
/// <summary>
/// The instance of the Installer resource used.
/// </summary>
public ExecutableResource Resource { get; } = installerResource;
}
39 changes: 39 additions & 0 deletions src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// 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;

/// <summary>
/// Represents the annotation for the JavaScript package manager used in a resource.
/// </summary>
/// <param name="packageManager">The name of the JavaScript package manager.</param>
public sealed class JavaScriptPackageManagerAnnotation(string packageManager) : IResourceAnnotation
{
/// <summary>
/// Gets the name of the JavaScript package manager.
/// </summary>
public string PackageManager { get; } = packageManager;

/// <summary>
/// Gets the command line arguments for the JavaScript package manager's install command.
/// </summary>
public string[] InstallCommandLineArgs { get; init; } = [];

/// <summary>
/// Gets the command line arguments for the JavaScript package manager's run command.
/// </summary>
public string[] RunCommandLineArgs { get; init; } = [];

/// <summary>
/// Gets a string value that separates the package manager command line args from the tool's command line args.
/// By default, this is "--".
/// </summary>
public string? CommandSeparator { get; init; } = "--";

/// <summary>
/// Gets the command line arguments for the JavaScript package manager's command that produces assets for distribution.
/// </summary>
public string[] BuildCommandLineArgs { get; init; } = [];
}
133 changes: 132 additions & 1 deletion src/Aspire.Hosting.NodeJs/NodeExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// 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;
using Microsoft.Extensions.Hosting;

Expand Down Expand Up @@ -69,7 +73,7 @@ public static IResourceBuilder<NodeAppResource> AddNpmApp(this IDistributedAppli
.WithIconName("CodeJsRectangle");
}

private static IResourceBuilder<NodeAppResource> WithNodeDefaults(this IResourceBuilder<NodeAppResource> builder) =>
private static IResourceBuilder<TResource> WithNodeDefaults<TResource>(this IResourceBuilder<TResource> builder) where TResource : NodeAppResource =>
builder.WithOtlpExporter()
.WithEnvironment("NODE_ENV", builder.ApplicationBuilder.Environment.IsDevelopment() ? "development" : "production")
.WithExecutableCertificateTrustCallback((ctx) =>
Expand All @@ -86,4 +90,131 @@ private static IResourceBuilder<NodeAppResource> WithNodeDefaults(this IResource

return Task.CompletedTask;
});

/// <summary>
/// Adds a Vite app to the distributed application builder.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the Vite app.</param>
/// <param name="workingDirectory">The working directory of the Vite app.</param>
/// <param name="useHttps">When true use HTTPS for the endpoints, otherwise use HTTP.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <example>
/// The following example creates a Vite app using npm as the package manager.
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddViteApp("frontend", "./frontend")
/// .WithNpmPackageManager();
///
/// builder.Build().Run();
/// </code>
/// </example>
/// </remarks>
public static IResourceBuilder<ViteAppResource> AddViteApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, bool useHttps = false)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(workingDirectory);

workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory));
var resource = new ViteAppResource(name, "node", workingDirectory);

var resourceBuilder = builder.AddResource(resource)
.WithNodeDefaults()
.WithIconName("CodeJsRectangle")
.WithArgs(c =>
{
if (resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out var packageManagerAnnotation))
{
foreach (var arg in packageManagerAnnotation.RunCommandLineArgs)
{
c.Args.Add(arg);
}
}
c.Args.Add("dev");

if (packageManagerAnnotation?.CommandSeparator is string separator)
{
c.Args.Add(separator);
}

var targetEndpoint = resource.GetEndpoint("https");
if (!targetEndpoint.Exists)
{
targetEndpoint = resource.GetEndpoint("http");
}

c.Args.Add("--port");
c.Args.Add(targetEndpoint.Property(EndpointProperty.TargetPort));
});

_ = useHttps
? resourceBuilder.WithHttpsEndpoint(env: "PORT")
: resourceBuilder.WithHttpEndpoint(env: "PORT");

return resourceBuilder
.PublishAsDockerFile(c =>
{
c.WithDockerfileBuilder(workingDirectory, dockerfileContext =>
{
if (c.Resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(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)}");
}
});
});
}

/// <summary>
/// Ensures the Node.js packages are installed before the application starts using npm as the package manager.
/// </summary>
/// <param name="resource">The NodeAppResource.</param>
/// <param name="useCI">When true, use <code>npm ci</code>, otherwise use <code>npm install</code> when installing packages.</param>
/// <param name="configureInstaller">Configure the npm installer resource.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<TResource> WithNpmPackageManager<TResource>(this IResourceBuilder<TResource> resource, bool useCI = false, Action<IResourceBuilder<NpmInstallerResource>>? configureInstaller = null) where TResource : NodeAppResource
Copy link
Member

@davidfowl davidfowl Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should delete this bool and make it part of the NpmInstallerResource and a callback

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useCI bool? The reason I left it in is so we could use the same thing in the docker build.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we can leave it for now. Imagined this as a method on the callback rather than this.

{
resource.WithCommand("npm");
resource.WithAnnotation(new JavaScriptPackageManagerAnnotation("npm")
{
InstallCommandLineArgs = [useCI ? "ci" : "install"],
RunCommandLineArgs = ["run"],
BuildCommandLineArgs = ["run", "build"]
});

// 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;
}
}
Loading