Aspire with Python, React, Rust and Node apps

Using Aspire to build a distributed application with Python, React, Rust and Node.js components

.NET

Aspire

Talks

Aspire (formerly .NET Aspire) is a great way to create observable, production-ready distributed apps by defining a code-based model of services, resources, and connections. It simplifies local development and debugging, as well as deployment.

Aspire logo

By their very nature, distributed applications will have at least a few (if not a lot) of components. This presents a challenge both for the local developer experience and for deployment. Aspire seeks to simplify this by allowing you to model the services, resources, and connections in code. With one command you can then not only launch everything locally, but ensure that each service knows how to connect to the other services it needs to function.

You can use it for both development and deployment, or if you already have an existing deployment process you’re happy with (eg. Infrastructure as Code/deployment pipelines) you can just use Aspire to simply your local development experience.

Check out my previous post Introducing Aspire for an overview of what Aspire is and how it works.

As part of this year’s .NET Conf virtual conference, I presented a talk “Taking .NET out of .NET Aspire - working with non-.NET applications”, in which I show how despite Aspire being written in .NET, it can integrate with a whole range of other software languages ecosystems. Here’s the recording of that presentation:

One issue with the talk was that it had to be pre-recorded a couple of weeks beforehand, so it was done using the Aspire 9.5 bits. Now that Aspire 13.0 is out, some of the packages that I used from the Community Toolkit in the demo are no longer necessary as Aspire has improved the integration with Python and Node.js applications.

The source code for the demo can be found at https://github.com/flcdrg/aspire-non-dotnet. A quick glance and you might think “there’s no .NET or Aspire in this repo” and if you just look at the main branch then you’d be right. There are separate branches where I incrementally integrate each component into Aspire.

The scenario I demo is a ‘Pet supplies’ e-commerce website, with a React front-end, Python backend API running with MongoDB. The Python backend also talks to a Rust-based payment gateway service, and as a bonus step right at the end I add a Node.js server app to provide random pet jokes!

Screenshot of Pet supplies web page

The technology mix is not unheard of, particularly at some larger organisations where you may have different teams working on different features or services and sometimes they are allowed enough autonomy to choose their own technology stack. Whether this is a case of “using the right tool for the job” or “must use the current shiny new thing” isn’t so important. The reality is that for good or other reasons you often find yourself in this situation.

MongoDB

MongoDB is supported out of the box in Aspire. The original process was to launch this via docker compose. Aspire actually supports compose files too (via the Aspire.Hosting.Docker package), but in this case I’m taking advantage of the Aspire.Hosting.MongoDB to configure the MongoDB service, database, and for bonus points, include the Mongo Express management UI. This will still run in a Docker container, but Aspire handles all the configuration for me.

var mongo = builder.AddMongoDB("mongo")
    .WithDataVolume()
    .WithMongoExpress();

var mongodb = mongo.AddDatabase("petstore");

WithDataVolume() means that a Docker volume is created to persist the database data between runs.

The original implementation also has a PowerShell script to populate the database with sample data. I wired up that script so that it gets run automatically when the MongoDB service starts up in Aspire.

var loadData = builder.AddExecutable("load-data", "pwsh", "../mongodb", "-noprofile", "./populate.ps1")
    .WaitFor(mongo)
    .WithArgs("-connectionString")
    .WithArgs(new ConnectionStringReference(mongo.Resource, false));
//.WithExplicitStart();

The script conveniently already had a parameter defined to pass in a connection string, so I take advantage of that.

If you’d prefer to just run the script manually (rather than every time you start Aspire) you could uncomment the .WithExplicitStart() method.

Rust

I’ve never used the Rust programming language before, but it is becoming increasingly popular, especially where you might previously have used C or C++. Rust uses Cargo as its package manager and build tool. Support for building and running Rust applications is provided by the Community Toolkit’s CommunityToolkit.Aspire.Hosting.Rust package.

Configuring the Rust application is quite straightforward with the AddRustApp method. The application has a default port it listens on but allows that to be overridden via the PAYMENT_API_PORT environment variable. Aspire will set that to the appropriate port number by the call to WithHttpEndpoint.

var rust = builder.AddRustApp("rustpaymentapi", "../RustPaymentApi", [])
    .WithHttpEndpoint(env: "PAYMENT_API_PORT");

Node.js

JavaScript is well known as a front end language, but platforms like Node.js allow you to write server-side applications in JavaScript (and TypeScript) too. Aspire 13.0 introduces improved support for JavaScript apps via a new Aspire.Hosting.JavaScript package. This includes the ability to configure using pnpm or yarn package managers (the default being npm).

var nodeApp = builder.AddJavaScriptApp("node-joke-api", "../NodeApp", "start")
    .WithPnpm()
    // If you are using fnm for Node.js version management, you might need to adjust the PATH
    .WithEnvironment("PATH", Environment.GetEnvironmentVariable("PATH") + ";" + Environment.ExpandEnvironmentVariables(@"%USERPROFILE%\AppData\Roaming\fnm\aliases\default"))
    .WithHttpEndpoint(env: "PORT")
    .WithOtlpExporter();

I use fnm (Fast Node Manager) to manage my Node.js versions. This means that the actual node executable is not in the PATH by default, but rather is added (or updated) dynamically as I change directory via my PowerShell profile script. Because that script isn’t run by Aspire, I explicity append the fnm default alias directory to the PATH environment variable so that node can be found.

Python

Python is an interesting ecosystem. There are a number of different package managers that will influence how you work with it. Aspire has first-class support for Python applications that use pip via the Aspire.Hosting.Python NuGet package. (See Python apps in Aspire for more details).

I recently worked on a client engagement where they were using uv with their Python applications. Aspire 13.0 now includes direct support for uv (via WithUv()) and uvicorn (with AddUvicornApp()):

var pythonApp = builder.AddUvicornApp("python-api", "../PythonUv", "src.api:app")
    .WithUv()
    .WaitFor(mongo)
    .WaitFor(rust)
    .WaitFor(nodeApp)
    .WithEnvironment("PYTHONIOENCODING", "utf-8")
    .WithEnvironment("MONGO_CONNECTION_STRING", new ConnectionStringReference(mongo.Resource, false))
    .WithEnvironment("PAYMENT_API_BASE_URL", new EndpointReference(rust.Resource, "http"))
    .WithEnvironment("NODE_APP_BASE_URL", ReferenceExpression.Create($"{nodeApp.Resource.GetEndpoint("http")}"))
    .WithHttpHealthCheck("/")
    .WithExternalHttpEndpoints();

We wait for the MongoDB service, the Rust payment service, and the Node.js joke service, so that they have all started before the Python app. We also set up environment variables to pass in the connection strings and endpoints that the Python app needs to connect to those services.

A Vite React Frontend web app

The same Aspire.Hosting.JavaScript package that we used to wire up the Node.js application can also be used for the frontend web app. Helpfully, it specifically includes support for Vite apps

var web = builder.AddViteApp("web", "../web-vite-react")
    .WithPnpm()
    // If you are using fnm for Node.js version management, you might need to adjust the PATH
    .WithEnvironment("PATH", Environment.GetEnvironmentVariable("PATH") + ";" + Environment.ExpandEnvironmentVariables(@"%USERPROFILE%\AppData\Roaming\fnm\aliases\default"))
    .WaitFor(pythonApp)
    .WithEnvironment("VITE_API_BASE_URL", new EndpointReference(pythonApp.Resource, "http"));

Again, because I use fnm to manage my Node.js versions, I need to append the fnm default alias directory to the PATH environment variable so that node can be found.

The frontend application only talks to the Python backend API, so we enusre that service has started first, and set up the VITE_API_BASE_URL environment variable so that the Vite app knows where to find the API.

And with that in place, we can run the entire distributed application locally with a single command:

dotnet run --project ./AspireAppHost/AspireAppHost.csproj --launch-profile http

Not all of the applications I’m using here support HTTPS self-signed development certificates, so I stick with running everything over HTTP for now. This is something that Aspire has improved on in 13.0. Obviously in a production deployment you’d want to use HTTPS everywhere. If I didn’t need to set the launch profile, then I could use the Aspire CLI instead:

aspire run

The nice thing about all this is I didn’t need to make any changes any of the front end or back end applications to get them to work with Aspire. All the configuration was done in the Aspire host application. This assumes that your applications have provided a ‘seam’ (eg. environment variables or command line arguments) to allow you to configure things like connection strings, ports, and endpoints.

Screenshot of Aspire dashboard showing resources page

Here’s the final version of AppHost.cs:

var builder = DistributedApplication.CreateBuilder(args);

// MongoDB
var mongo = builder.AddMongoDB("mongo")
    .WithDataVolume()
    .WithMongoExpress();

var mongodb = mongo.AddDatabase("petstore");

var loadData = builder.AddExecutable("load-data", "pwsh", "../mongodb", "-noprofile", "./populate.ps1")
    .WaitFor(mongo)
    .WithArgs("-connectionString")
    .WithArgs(new ConnectionStringReference(mongo.Resource, false));
//.WithExplicitStart();

// Rust service
var rust = builder.AddRustApp("rustpaymentapi", "../RustPaymentApi", [])
    .WithHttpEndpoint(env: "PAYMENT_API_PORT");

// Node.js App
var nodeApp = builder.AddJavaScriptApp("node-joke-api", "../NodeApp", "start")
    .WithPnpm()
    // If you are using fnm for Node.js version management, you might need to adjust the PATH
    .WithEnvironment("PATH", Environment.GetEnvironmentVariable("PATH") + ";" + Environment.ExpandEnvironmentVariables(@"%USERPROFILE%\AppData\Roaming\fnm\aliases\default"))
    .WithHttpEndpoint(env: "PORT")
    .WithOtlpExporter();

// Python API
var pythonApp = builder.AddUvicornApp("python-api", "../PythonUv", "src.api:app")
    .WithUv()
    .WaitFor(mongo)
    .WaitFor(rust)
    .WaitFor(nodeApp)
    .WithEnvironment("PYTHONIOENCODING", "utf-8")
    .WithEnvironment("MONGO_CONNECTION_STRING", new ConnectionStringReference(mongo.Resource, false))
    .WithEnvironment("PAYMENT_API_BASE_URL", new EndpointReference(rust.Resource, "http"))
    .WithEnvironment("NODE_APP_BASE_URL", ReferenceExpression.Create($"{nodeApp.Resource.GetEndpoint("http")}"))
    .WithHttpHealthCheck("/")
    .WithExternalHttpEndpoints();

// Frontend
var web = builder.AddViteApp("web", "../web-vite-react")
    .WithPnpm()
    // If you are using fnm for Node.js version management, you might need to adjust the PATH
    .WithEnvironment("PATH", Environment.GetEnvironmentVariable("PATH") + ";" + Environment.ExpandEnvironmentVariables(@"%USERPROFILE%\AppData\Roaming\fnm\aliases\default"))
    .WaitFor(pythonApp)
    .WithEnvironment("VITE_API_BASE_URL", new EndpointReference(pythonApp.Resource, "http"));

builder.Build().Run();

To get the full benefit of Aspire you will want to take a look at adding OpenTelemetry instrumentation. If you already have that in place then there’s probably nothing to change. Aspire will set a bunch of OpenTelemetry-related environment variables for each application. If that’a new thing then you’ll get the double benefit of then being able to take advantage of that telemetry when your applications are deployed to production too!

Screenshot of Aspire dashboard showing traces page

Screenshot of Aspire dashboard showing metrics page

Conclusion

Aspire has the potential to greatly simplify and improve the local development experience for distributed applications - potentially removing the need for numerous scripts and manual steps for a developer to get up and running much more quickly. It’s also a tool that can benefit almost every development team, regardless of the technology stack they are using.