Skip to content
Merged
3,292 changes: 0 additions & 3,292 deletions examples/nodejs-ext/yarn-demo/package-lock.json

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Lifecycle;
using CommunityToolkit.Aspire.Hosting.NodeJS.Extensions;
using CommunityToolkit.Aspire.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace Aspire.Hosting;
Expand Down Expand Up @@ -116,8 +113,21 @@ public static IResourceBuilder<NodeAppResource> AddPnpmApp(this IDistributedAppl
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<NodeAppResource> WithNpmPackageInstallation(this IResourceBuilder<NodeAppResource> resource, bool useCI = false)
{
resource.ApplicationBuilder.Services.TryAddLifecycleHook<NpmPackageInstallerLifecycleHook>(sp =>
new(useCI, sp.GetRequiredService<ResourceLoggerService>(), sp.GetRequiredService<ResourceNotificationService>(), sp.GetRequiredService<DistributedApplicationExecutionContext>()));
// 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);
}

return resource;
}

Expand All @@ -128,7 +138,21 @@ public static IResourceBuilder<NodeAppResource> WithNpmPackageInstallation(this
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<NodeAppResource> WithYarnPackageInstallation(this IResourceBuilder<NodeAppResource> resource)
{
resource.ApplicationBuilder.Services.TryAddLifecycleHook<YarnPackageInstallerLifecycleHook>();
// Only install packages during development, not in publish mode
if (!resource.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
var installerName = $"{resource.Resource.Name}-yarn-install";
var installer = new YarnInstallerResource(installerName, resource.Resource.WorkingDirectory);

var installerBuilder = resource.ApplicationBuilder.AddResource(installer)
.WithArgs(["install"])
.WithParentRelationship(resource.Resource)
.ExcludeFromManifest();

// Make the parent resource wait for the installer to complete
resource.WaitForCompletion(installerBuilder);
}

return resource;
}

Expand All @@ -139,7 +163,21 @@ public static IResourceBuilder<NodeAppResource> WithYarnPackageInstallation(this
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<NodeAppResource> WithPnpmPackageInstallation(this IResourceBuilder<NodeAppResource> resource)
{
resource.ApplicationBuilder.Services.TryAddLifecycleHook<PnpmPackageInstallerLifecycleHook>();
// Only install packages during development, not in publish mode
if (!resource.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
var installerName = $"{resource.Resource.Name}-pnpm-install";
var installer = new PnpmInstallerResource(installerName, resource.Resource.WorkingDirectory);

var installerBuilder = resource.ApplicationBuilder.AddResource(installer)
.WithArgs(["install"])
.WithParentRelationship(resource.Resource)
.ExcludeFromManifest();

// Make the parent resource wait for the installer to complete
resource.WaitForCompletion(installerBuilder);
}

return resource;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ namespace CommunityToolkit.Aspire.Hosting.NodeJS.Extensions;
/// <param name="lockfile">The name of the lockfile to use.</param>
/// <param name="loggerService">The logger service to use.</param>
/// <param name="notificationService">The notification service to use.</param>
[Obsolete("This class is used by deprecated lifecycle hooks. Package installation is now handled by installer resources.")]
internal class NodePackageInstaller(string packageManager, string installCommand, string lockfile, ResourceLoggerService loggerService, ResourceNotificationService notificationService)
{
private readonly bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// A resource that represents an npm package installer.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="workingDirectory">The working directory to use for the command.</param>
public class NpmInstallerResource(string name, string workingDirectory)
: ExecutableResource(name, "npm", workingDirectory);
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ namespace CommunityToolkit.Aspire.Hosting.NodeJS.Extensions;
/// <param name="loggerService">The logger service used for logging.</param>
/// <param name="notificationService">The notification service used for sending notifications.</param>
/// <param name="context">The execution context of the distributed application.</param>
[Obsolete("Use WithNpmPackageInstallation which now creates installer resources instead of lifecycle hooks. This class will be removed in a future version.")]
internal class NpmPackageInstallerLifecycleHook(
bool useCI,
ResourceLoggerService loggerService,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// A resource that represents a pnpm package installer.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="workingDirectory">The working directory to use for the command.</param>
public class PnpmInstallerResource(string name, string workingDirectory)
: ExecutableResource(name, "pnpm", workingDirectory);
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace CommunityToolkit.Aspire.Hosting.NodeJS.Extensions;
/// <param name="loggerService">The <see cref="ResourceLoggerService"/> to use for logging.</param>
/// <param name="notificationService">The <see cref="ResourceNotificationService"/> to use for notifications to Aspire on install progress.</param>
/// <param name="context">The <see cref="DistributedApplicationExecutionContext"/> to use for determining if the application is in publish mode.</param>
[Obsolete("Use WithPnpmPackageInstallation which now creates installer resources instead of lifecycle hooks. This class will be removed in a future version.")]
internal class PnpmPackageInstallerLifecycleHook(
ResourceLoggerService loggerService,
ResourceNotificationService notificationService,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Node.js Package Installer Refactoring

This refactoring transforms the Node.js package installers from lifecycle hooks to ExecutableResource-based resources, addressing issue #732.

## What Changed

### Before (Lifecycle Hook Approach)
- Package installation was handled by lifecycle hooks during `BeforeStartAsync`
- No visibility into installation progress in the dashboard
- Limited logging capabilities
- Process management handled manually via `Process.Start`

### After (Resource-Based Approach)
- Package installers are now proper `ExecutableResource` instances
- They appear as separate resources in the Aspire dashboard
- Full console output visibility and logging
- DCP (Distributed Application Control Plane) handles process management
- Parent-child relationships ensure proper startup ordering

## New Resource Classes

### NpmInstallerResource
```csharp
var installer = new NpmInstallerResource("npm-installer", "/path/to/project", useCI: true);
// Supports both 'npm install' and 'npm ci' commands
```

### YarnInstallerResource
```csharp
var installer = new YarnInstallerResource("yarn-installer", "/path/to/project");
// Executes 'yarn install' command
```

### PnpmInstallerResource
```csharp
var installer = new PnpmInstallerResource("pnpm-installer", "/path/to/project");
// Executes 'pnpm install' command
```

## Usage Examples

### Basic Usage (No API Changes)
```csharp
var builder = DistributedApplication.CreateBuilder();

// API remains the same - behavior is now resource-based
var viteApp = builder.AddViteApp("frontend", "./frontend")
.WithNpmPackageInstallation(useCI: true);

var backendApp = builder.AddYarnApp("backend", "./backend")
.WithYarnPackageInstallation();
```

### What Happens Under the Hood
```csharp
// This now creates:
// 1. NodeAppResource named "frontend"
// 2. NpmInstallerResource named "frontend-npm-install" (child of frontend)
// 3. WaitAnnotation on frontend to wait for installer completion
// 4. ResourceRelationshipAnnotation linking installer to parent
```

## Benefits

### Dashboard Visibility
- Installer resources appear as separate items in the Aspire dashboard
- Real-time console output from package installation
- Clear status indication (starting, running, completed, failed)
- Ability to re-run installations if needed

### Better Resource Management
- DCP handles process lifecycle instead of manual `Process.Start`
- Proper resource cleanup and error handling
- Integration with Aspire's logging and monitoring systems

### Improved Startup Ordering
- Parent resources automatically wait for installer completion
- Failed installations prevent app startup (fail-fast behavior)
- Clear dependency visualization in the dashboard

### Development vs Production
- Installers only run during development (excluded from publish mode)
- No overhead in production deployments
- Maintains backward compatibility

## Migration Guide

### For Users
No changes required! The existing APIs (`WithNpmPackageInstallation`, `WithYarnPackageInstallation`, `WithPnpmPackageInstallation`) work exactly the same.

### For Contributors
The lifecycle hook classes are marked as `[Obsolete]` but remain functional for backward compatibility:
- `NpmPackageInstallerLifecycleHook`
- `YarnPackageInstallerLifecycleHook`
- `PnpmPackageInstallerLifecycleHook`
- `NodePackageInstaller`

These will be removed in a future version once all usage has migrated to the resource-based approach.

## Testing

Comprehensive test coverage includes:
- Unit tests for installer resource properties and command generation
- Integration tests for parent-child relationships
- Cross-platform compatibility (Windows vs Unix commands)
- Publish mode exclusion verification
- Wait annotation and resource relationship validation
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Runtime.InteropServices;

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// A resource that represents a yarn package installer.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="workingDirectory">The working directory to use for the command.</param>
public class YarnInstallerResource(string name, string workingDirectory)
: ExecutableResource(name, "yarn", workingDirectory);
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace CommunityToolkit.Aspire.Hosting.NodeJS.Extensions;
/// <param name="loggerService">The <see cref="ResourceLoggerService"/> to use for logging.</param>
/// <param name="notificationService">The <see cref="ResourceNotificationService"/> to use for notifications to Aspire on install progress.</param>
/// <param name="context">The <see cref="DistributedApplicationExecutionContext"/> to use for determining if the application is in publish mode.</param>
[Obsolete("Use WithYarnPackageInstallation which now creates installer resources instead of lifecycle hooks. This class will be removed in a future version.")]
internal class YarnPackageInstallerLifecycleHook(
ResourceLoggerService loggerService,
ResourceNotificationService notificationService,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;

namespace CommunityToolkit.Aspire.Hosting.NodeJS.Extensions.Tests;

/// <summary>
/// Integration test that demonstrates the new resource-based package installer architecture.
/// This shows how installer resources appear as separate resources in the application model.
/// </summary>
public class IntegrationTests
{
[Fact]
public void ResourceBasedPackageInstallersAppearInApplicationModel()
{
var builder = DistributedApplication.CreateBuilder();

// Add multiple Node.js apps with different package managers
var viteApp = builder.AddViteApp("vite-app", "./frontend")
.WithNpmPackageInstallation(useCI: true);

var yarnApp = builder.AddYarnApp("yarn-app", "./backend")
.WithYarnPackageInstallation();

var pnpmApp = builder.AddPnpmApp("pnpm-app", "./admin")
.WithPnpmPackageInstallation();

using var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();

// Verify all Node.js app resources are present
var nodeResources = appModel.Resources.OfType<NodeAppResource>().ToList();
Assert.Equal(3, nodeResources.Count);

// Verify all installer resources are present as separate resources
var npmInstallers = appModel.Resources.OfType<NpmInstallerResource>().ToList();
var yarnInstallers = appModel.Resources.OfType<YarnInstallerResource>().ToList();
var pnpmInstallers = appModel.Resources.OfType<PnpmInstallerResource>().ToList();

Assert.Single(npmInstallers);
Assert.Single(yarnInstallers);
Assert.Single(pnpmInstallers);

// Verify installer resources have expected names (would appear on dashboard)
Assert.Equal("vite-app-npm-install", npmInstallers[0].Name);
Assert.Equal("yarn-app-yarn-install", yarnInstallers[0].Name);
Assert.Equal("pnpm-app-pnpm-install", pnpmInstallers[0].Name);

// Verify parent-child relationships
foreach (var installer in npmInstallers.Cast<IResource>()
.Concat(yarnInstallers.Cast<IResource>())
.Concat(pnpmInstallers.Cast<IResource>()))
{
Assert.True(installer.TryGetAnnotationsOfType<ResourceRelationshipAnnotation>(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<WaitAnnotation>(out var waitAnnotations));
Assert.Single(waitAnnotations);

var waitedResource = waitAnnotations.First().Resource;
Assert.True(waitedResource is NpmInstallerResource ||
waitedResource is YarnInstallerResource ||
waitedResource is PnpmInstallerResource);
}
}

[Fact]
public void InstallerResourcesHaveCorrectExecutableConfiguration()
{
var builder = DistributedApplication.CreateBuilder();

var nodeApp = builder.AddNpmApp("test-app", "./test")
.WithNpmPackageInstallation(useCI: true);

using var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();

var installer = Assert.Single(appModel.Resources.OfType<NpmInstallerResource>());

// Verify it's configured as an ExecutableResource
Assert.IsAssignableFrom<ExecutableResource>(installer);

// Verify working directory matches parent
var parentApp = Assert.Single(appModel.Resources.OfType<NodeAppResource>());
Assert.Equal(parentApp.WorkingDirectory, installer.WorkingDirectory);

// Verify command arguments are configured
Assert.True(installer.TryGetAnnotationsOfType<CommandLineArgsCallbackAnnotation>(out var argsAnnotations));
}
}
Loading
Loading