Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,64 @@ public static IResourceBuilder<TurborepoResource> AddTurborepoApp(this IDistribu
.WithInitialState(new CustomResourceSnapshot { Properties = [], ResourceType = "TurborepoWorkspace", State = KnownResourceStates.Running });
}

/// <summary>
/// Adds a yarn workspace 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 yarn workspace resource.</param>
/// <param name="workingDirectory">The working directory of the workspace. If not specified, it will be set to a path that is a sibling of the AppHost directory using the <paramref name="name"/> as the folder.</param>
/// <param name="install">When true (default), automatically installs packages before apps start. When false, only sets the package manager annotation without creating an installer resource.</param>
/// <param name="configureInstaller">A function to configure the installer resource builder.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<YarnWorkspaceResource> AddYarnWorkspaceApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string? workingDirectory = null, bool install = true, Action<IResourceBuilder<JavaScriptInstallerResource>>? configureInstaller = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);

string wd = workingDirectory ?? Path.Combine("..", name);
workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, wd));

var resource = new YarnWorkspaceResource(name, workingDirectory);

var rb = builder.AddResource(resource)
.WithIconName("CodeJsRectangle")
.WithInitialState(new CustomResourceSnapshot { Properties = [], ResourceType = "YarnWorkspace", State = KnownResourceStates.Running })
.WithAnnotation(new JavaScriptPackageManagerAnnotation("yarn", "run"));

AddMonorepoInstaller(rb, "yarn", install, configureInstaller);

return rb;
}

/// <summary>
/// Adds a pnpm workspace 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 pnpm workspace resource.</param>
/// <param name="workingDirectory">The working directory of the workspace. If not specified, it will be set to a path that is a sibling of the AppHost directory using the <paramref name="name"/> as the folder.</param>
/// <param name="install">When true (default), automatically installs packages before apps start. When false, only sets the package manager annotation without creating an installer resource.</param>
/// <param name="configureInstaller">A function to configure the installer resource builder.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<PnpmWorkspaceResource> AddPnpmWorkspaceApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string? workingDirectory = null, bool install = true, Action<IResourceBuilder<JavaScriptInstallerResource>>? configureInstaller = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);

string wd = workingDirectory ?? Path.Combine("..", name);
workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, wd));

var resource = new PnpmWorkspaceResource(name, workingDirectory);

var rb = builder.AddResource(resource)
.WithIconName("CodeJsRectangle")
.WithInitialState(new CustomResourceSnapshot { Properties = [], ResourceType = "PnpmWorkspace", State = KnownResourceStates.Running })
.WithAnnotation(new JavaScriptPackageManagerAnnotation("pnpm", "run"));

AddMonorepoInstaller(rb, "pnpm", install, configureInstaller);

return rb;
}

/// <summary>
/// Adds an individual app to an Nx workspace.
/// </summary>
Expand Down Expand Up @@ -152,6 +210,74 @@ public static IResourceBuilder<TurborepoAppResource> AddApp(this IResourceBuilde
return rb;
}

/// <summary>
/// Adds an individual app from a yarn workspace.
/// </summary>
/// <param name="builder">The yarn workspace resource builder.</param>
/// <param name="name">The name of the app resource.</param>
/// <param name="workspaceName">The yarn workspace package name to run. Defaults to <paramref name="name"/>.</param>
/// <param name="configure">A function to configure the app resource builder.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<YarnWorkspaceAppResource> AddApp(this IResourceBuilder<YarnWorkspaceResource> builder, [ResourceName] string name, string? workspaceName = null, Func<IResourceBuilder<YarnWorkspaceAppResource>, IResourceBuilder<YarnWorkspaceAppResource>>? configure = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);

workspaceName ??= name;

var resource = new YarnWorkspaceAppResource(name, builder.Resource.WorkingDirectory, workspaceName);

var rb = builder.ApplicationBuilder.AddResource(resource)
.WithNodeDefaults()
.WithIconName("CodeJsRectangle")
.WithArgs("workspace", workspaceName, "run", "dev")
.WithParentRelationship(builder.Resource);

// If the workspace has an installer annotation, wait for the installer to complete
if (builder.Resource.TryGetLastAnnotation<JavaScriptPackageInstallerAnnotation>(out var installerAnnotation))
{
rb.WaitForCompletion(builder.ApplicationBuilder.CreateResourceBuilder(installerAnnotation.Resource));
}

configure?.Invoke(rb);

return rb;
}

/// <summary>
/// Adds an individual app from a pnpm workspace.
/// </summary>
/// <param name="builder">The pnpm workspace resource builder.</param>
/// <param name="name">The name of the app resource.</param>
/// <param name="filter">The pnpm filter to use. Defaults to <paramref name="name"/>.</param>
/// <param name="configure">A function to configure the app resource builder.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<PnpmWorkspaceAppResource> AddApp(this IResourceBuilder<PnpmWorkspaceResource> builder, [ResourceName] string name, string? filter = null, Func<IResourceBuilder<PnpmWorkspaceAppResource>, IResourceBuilder<PnpmWorkspaceAppResource>>? configure = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);

filter ??= name;

var resource = new PnpmWorkspaceAppResource(name, builder.Resource.WorkingDirectory, filter);

var rb = builder.ApplicationBuilder.AddResource(resource)
.WithNodeDefaults()
.WithIconName("CodeJsRectangle")
.WithArgs("--filter", filter, "run", "dev")
.WithParentRelationship(builder.Resource);

// If the workspace has an installer annotation, wait for the installer to complete
if (builder.Resource.TryGetLastAnnotation<JavaScriptPackageInstallerAnnotation>(out var installerAnnotation))
{
rb.WaitForCompletion(builder.ApplicationBuilder.CreateResourceBuilder(installerAnnotation.Resource));
}

configure?.Invoke(rb);

return rb;
}

/// <summary>
/// Configures the Nx workspace to use the specified JavaScript package manager when starting apps.
/// </summary>
Expand Down Expand Up @@ -364,6 +490,8 @@ private static void AddMonorepoInstaller<TResource>(
{
NxResource nx => nx.WorkingDirectory,
TurborepoResource turbo => turbo.WorkingDirectory,
YarnWorkspaceResource yarn => yarn.WorkingDirectory,
PnpmWorkspaceResource pnpm => pnpm.WorkingDirectory,
_ => throw new InvalidOperationException($"Unsupported resource type: {builder.Resource.GetType().Name}")
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Frontend Monorepo Support

This extension now provides built-in support for frontend monorepos using [Nx](https://nx.dev) and [Turborepo](https://turborepo.com).
This extension now provides built-in support for frontend monorepos using [Nx](https://nx.dev) and [Turborepo](https://turborepo.com), plus native package-manager workspaces with yarn and pnpm.

## The Problem

Expand Down Expand Up @@ -44,6 +44,23 @@ var app2 = turbo.AddApp("app2", filter: "custom-filter"); // Custom filter
var app3 = turbo.AddApp("app3");
```

### Yarn and pnpm workspaces (native)

For projects that already use yarn or pnpm workspaces without Nx/Turbo, you can still get a shared installer and per-app execution helpers:

```csharp
var yarn = builder.AddYarnWorkspaceApp("yarn-workspace", workingDirectory: "../frontend"); // Single shared installer

var app1 = yarn.AddApp("app1"); // Runs: yarn workspace app1 run dev
var app2 = yarn.AddApp("app2", workspaceName: "custom-name"); // Custom workspace name. Runs: yarn workspace custom-name run dev

var pnpm = builder.AddPnpmWorkspaceApp("pnpm-workspace", workingDirectory: "../frontend"); // Single shared installer

var app1 = pnpm.AddApp("app1"); // Runs: pnpm --filter app1 run dev

var app2 = pnpm.AddApp("app2", filter: "custom-filter"); // Custom filter. Runs: pnpm --filter custom-filter run dev
```

## Package Managers

Both Nx and Turborepo support yarn and pnpm package managers:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Aspire.Hosting.JavaScript;

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// A resource that represents a JavaScript application running from a pnpm workspace package.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="workingDirectory">The working directory of the workspace.</param>
/// <param name="filter">The pnpm filter to use when running the package. (used in pnpm --filter &lt;filter&gt; run dev)</param>
/// <param name="command">The command to run (default is 'pnpm').</param>
public class PnpmWorkspaceAppResource(string name, string workingDirectory, string filter, string command = "pnpm")
: JavaScriptAppResource(name, command, workingDirectory)
{
/// <summary>
/// Gets the pnpm filter used for the workspace package.
/// </summary>
public string Filter { get; } = filter;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// A resource that represents a pnpm workspace.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="workingDirectory">The working directory of the pnpm workspace.</param>
public class PnpmWorkspaceResource(string name, string workingDirectory) : Resource(name)
{
/// <summary>
/// Gets the working directory of the pnpm workspace.
/// </summary>
public string WorkingDirectory { get; } = workingDirectory;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CommunityToolkit.Aspire.Hosting.JavaScript.Extensions library

This integration contains extensions for the [Node.js hosting package](https://nuget.org/packages/Aspire.Hosting.JavaScript) for Aspire, including support for frontend monorepos (Nx, Turborepo).
This integration contains extensions for the [Node.js hosting package](https://nuget.org/packages/Aspire.Hosting.JavaScript) for Aspire, including support for frontend monorepos (Nx, Turborepo) and native package-manager workspaces (yarn, pnpm).

## Getting Started

Expand All @@ -14,7 +14,7 @@ dotnet add package CommunityToolkit.Aspire.Hosting.JavaScript.Extensions

### Example usage

For Nx and Turborepo monorepos, use the dedicated monorepo methods to avoid package installation race conditions:
For Nx, Turborepo, and package-manager workspaces, use the dedicated helpers to avoid package installation race conditions:

```csharp
// Nx workspace
Expand All @@ -31,6 +31,14 @@ var turbo = builder.AddTurborepoApp("turbo", workingDirectory: "../frontend")

var turboApp1 = turbo.AddApp("app1");
var turboApp2 = turbo.AddApp("app2", filter: "custom-filter");

// Yarn workspace (package-manager native)
var yarn = builder.AddYarnWorkspaceApp("yarn-workspace", workingDirectory: "../frontend", install: true);
yarn.AddApp("yarn-web", workspaceName: "web");

// pnpm workspace (package-manager native)
var pnpm = builder.AddPnpmWorkspaceApp("pnpm-workspace", workingDirectory: "../frontend", install: true);
pnpm.AddApp("pnpm-web", filter: "web");
```

See [MONOREPO.md](./MONOREPO.md) for detailed documentation on monorepo support.
Expand All @@ -53,6 +61,8 @@ var turbo = builder.AddTurborepoApp("turbo", workingDirectory: "../frontend")
// Generated commands:
// Nx with yarn: yarn nx serve app1
// Turborepo with pnpm: pnpm turbo run dev --filter app1
// Yarn workspace app: yarn workspace app1 run dev
// pnpm workspace app: pnpm --filter app1 run dev
```

### Package installation with custom flags
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Aspire.Hosting.JavaScript;

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// A resource that represents a JavaScript application running from a yarn workspace package.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="workingDirectory">The working directory of the workspace.</param>
/// <param name="workspaceName">The yarn workspace package name to run. (used in yarn workspace &lt;workspaceName&gt; run dev)</param>
/// <param name="command">The command to run (default is 'yarn').</param>
public class YarnWorkspaceAppResource(string name, string workingDirectory, string workspaceName, string command = "yarn")
: JavaScriptAppResource(name, command, workingDirectory)
{
/// <summary>
/// Gets the yarn workspace package name.
/// </summary>
public string WorkspaceName { get; } = workspaceName;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// A resource that represents a yarn workspace.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="workingDirectory">The working directory of the yarn workspace.</param>
public class YarnWorkspaceResource(string name, string workingDirectory) : Resource(name)
{
/// <summary>
/// Gets the working directory of the yarn workspace.
/// </summary>
public string WorkingDirectory { get; } = workingDirectory;
}
Loading