|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.IO.Pipes;
using System.Net.Sockets;
using System.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Certificates;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Semver;
namespace Aspire.Cli.Projects;
/// <summary>
/// Handler for TypeScript AppHost projects (apphost.ts).
/// </summary>
internal sealed class TypeScriptAppHostProject : IAppHostProject
{
private const string GeneratedFolderName = ".modules";
private readonly IInteractionService _interactionService;
private readonly IAppHostCliBackchannel _backchannel;
private readonly IAppHostServerProjectFactory _appHostServerProjectFactory;
private readonly ICertificateService _certificateService;
private readonly IDotNetCliRunner _runner;
private readonly IPackagingService _packagingService;
private readonly IConfiguration _configuration;
private readonly IFeatures _features;
private readonly ILogger<TypeScriptAppHostProject> _logger;
private readonly TimeProvider _timeProvider;
private readonly RunningInstanceManager _runningInstanceManager;
private static readonly string[] s_detectionPatterns = ["apphost.ts"];
public TypeScriptAppHostProject(
IInteractionService interactionService,
IAppHostCliBackchannel backchannel,
IAppHostServerProjectFactory appHostServerProjectFactory,
ICertificateService certificateService,
IDotNetCliRunner runner,
IPackagingService packagingService,
IConfiguration configuration,
IFeatures features,
ILogger<TypeScriptAppHostProject> logger,
TimeProvider? timeProvider = null)
{
_interactionService = interactionService;
_backchannel = backchannel;
_appHostServerProjectFactory = appHostServerProjectFactory;
_certificateService = certificateService;
_runner = runner;
_packagingService = packagingService;
_configuration = configuration;
_features = features;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
_runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider);
}
// ═══════════════════════════════════════════════════════════════
// IDENTITY
// ═══════════════════════════════════════════════════════════════
/// <inheritdoc />
public string LanguageId => KnownLanguageId.TypeScript;
/// <inheritdoc />
public string DisplayName => "TypeScript (Node.js)";
// ═══════════════════════════════════════════════════════════════
// DETECTION
// ═══════════════════════════════════════════════════════════════
/// <inheritdoc />
public string[] DetectionPatterns => s_detectionPatterns;
/// <inheritdoc />
public bool CanHandle(FileInfo appHostFile)
{
// Must be named apphost.ts
if (!appHostFile.Name.Equals("apphost.ts", StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Check no sibling .csproj files (those take precedence)
var siblingCsprojFiles = appHostFile.Directory!.EnumerateFiles("*.csproj", SearchOption.TopDirectoryOnly);
if (siblingCsprojFiles.Any())
{
return false;
}
// Check for package.json
var directory = appHostFile.Directory!;
var hasPackageJson = File.Exists(Path.Combine(directory.FullName, "package.json"));
return hasPackageJson;
}
// ═══════════════════════════════════════════════════════════════
// CREATION
// ═══════════════════════════════════════════════════════════════
/// <inheritdoc />
public string AppHostFileName => "apphost.ts";
/// <inheritdoc />
public async Task ScaffoldAsync(DirectoryInfo directory, string? projectName, CancellationToken cancellationToken)
{
var appHostPath = Path.Combine(directory.FullName, "apphost.ts");
var packageJsonPath = Path.Combine(directory.FullName, "package.json");
// Create a TypeScript apphost that uses the generated Aspire SDK
var appHostContent = """
// Aspire TypeScript AppHost
// For more information, see: https://aspire.dev
import { createBuilder } from './.modules/aspire.js';
const builder = await createBuilder();
// Add your resources here, for example:
// const redis = await builder.addContainer("cache", "redis:latest");
// const postgres = await builder.addPostgres("db");
await builder.build().run();
""";
await File.WriteAllTextAsync(appHostPath, appHostContent, cancellationToken);
// Create package.json if it doesn't exist
if (!File.Exists(packageJsonPath))
{
var packageName = projectName?.ToLowerInvariant() ?? "aspire-apphost";
var packageJsonContent = $$"""
{
"name": "{{packageName}}",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "aspire run"
},
"dependencies": {
"vscode-jsonrpc": "^8.2.0"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.3.0",
"@types/node": "^20.0.0"
}
}
""";
await File.WriteAllTextAsync(packageJsonPath, packageJsonContent, cancellationToken);
}
// Create apphost.run.json for dashboard/OTLP configuration
var apphostRunJsonPath = Path.Combine(directory.FullName, "apphost.run.json");
if (!File.Exists(apphostRunJsonPath))
{
// Generate random 5-digit ports (10000-65000)
var httpsPort = Random.Shared.Next(10000, 65000);
var httpPort = Random.Shared.Next(10000, 65000);
var otlpPort = Random.Shared.Next(10000, 65000);
var resourceServicePort = Random.Shared.Next(10000, 65000);
var apphostRunJsonContent = $$"""
{
"profiles": {
"https": {
"applicationUrl": "https://localhost:{{httpsPort}};http://localhost:{{httpPort}}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:{{otlpPort}}",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:{{resourceServicePort}}"
}
}
}
}
""";
await File.WriteAllTextAsync(apphostRunJsonPath, apphostRunJsonContent, cancellationToken);
}
// Build the AppHost server and generate TypeScript SDK
await BuildAndGenerateSdkAsync(directory, cancellationToken);
}
/// <summary>
/// Creates project files and builds the AppHost server.
/// </summary>
private static async Task<(bool Success, OutputCollector Output, string? ChannelName)> BuildAppHostServerAsync(
AppHostServerProject appHostServerProject,
List<(string Name, string Version)> packages,
CancellationToken cancellationToken)
{
var outputCollector = new OutputCollector();
var (_, channelName) = await appHostServerProject.CreateProjectFilesAsync(packages, cancellationToken);
var (buildSuccess, buildOutput) = await appHostServerProject.BuildAsync(cancellationToken);
if (!buildSuccess)
{
foreach (var (_, line) in buildOutput.GetLines())
{
outputCollector.AppendOutput(line);
}
}
return (buildSuccess, outputCollector, channelName);
}
/// <summary>
/// Builds the AppHost server project and generates the TypeScript SDK.
/// </summary>
private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken)
{
// Step 1: Run npm install if node_modules doesn't exist
var nodeModulesPath = Path.Combine(directory.FullName, "node_modules");
if (!Directory.Exists(nodeModulesPath))
{
var npmInstallResult = await RunNpmInstallAsync(directory, cancellationToken);
if (npmInstallResult != 0)
{
_interactionService.DisplayError("Failed to install npm dependencies.");
return;
}
}
// Step 2: Get package references and build AppHost server
var packages = GetPackageReferences(directory).ToList();
var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName);
var socketPath = appHostServerProject.GetSocketPath();
var (buildSuccess, buildOutput, _) = await BuildAppHostServerAsync(appHostServerProject, packages, cancellationToken);
if (!buildSuccess)
{
_interactionService.DisplayLines(buildOutput.GetLines());
_interactionService.DisplayError("Failed to build AppHost server.");
return;
}
// Step 3: Start the AppHost server temporarily for code generation
var currentPid = Environment.ProcessId;
var (serverProcess, _) = appHostServerProject.Run(socketPath, currentPid, new Dictionary<string, string>());
try
{
// Step 4: Generate TypeScript SDK via RPC
await GenerateCodeViaRpcAsync(
directory.FullName,
socketPath,
packages,
cancellationToken);
}
finally
{
// Step 5: Stop the server (we were just generating code)
if (!serverProcess.HasExited)
{
try
{
serverProcess.Kill(entireProcessTree: true);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error killing AppHost server process after code generation");
}
}
}
}
// ═══════════════════════════════════════════════════════════════
// EXECUTION
// ═══════════════════════════════════════════════════════════════
/// <inheritdoc />
public Task<AppHostValidationResult> ValidateAppHostAsync(FileInfo appHostFile, CancellationToken cancellationToken)
{
// Check if the file exists and has the correct extension
if (!appHostFile.Exists)
{
return Task.FromResult(new AppHostValidationResult(IsValid: false));
}
if (!appHostFile.Name.Equals("apphost.ts", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(new AppHostValidationResult(IsValid: false));
}
// Check for package.json in the same directory
var directory = appHostFile.Directory;
if (directory is null)
{
return Task.FromResult(new AppHostValidationResult(IsValid: false));
}
var hasPackageJson = File.Exists(Path.Combine(directory.FullName, "package.json"));
// TypeScript doesn't have the "possibly unbuildable" concept
return Task.FromResult(new AppHostValidationResult(IsValid: hasPackageJson));
}
/// <inheritdoc />
public async Task<int> RunAsync(AppHostProjectContext context, CancellationToken cancellationToken)
{
var appHostFile = context.AppHostFile;
var directory = appHostFile.Directory!;
_logger.LogDebug("Running TypeScript AppHost: {AppHostFile}", appHostFile.FullName);
try
{
// Step 1: Ensure certificates are trusted
try
{
await _certificateService.EnsureCertificatesTrustedAsync(_runner, cancellationToken);
}
catch
{
context.BuildCompletionSource?.TrySetResult(false);
throw;
}
// Build phase: npm install, build AppHost server, generate SDK
var packages = GetPackageReferences(directory).ToList();
var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName);
var socketPath = appHostServerProject.GetSocketPath();
var buildResult = await _interactionService.ShowStatusAsync(
":hammer_and_wrench: Building app host...",
async () =>
{
// Run npm install if node_modules doesn't exist
var nodeModulesPath = Path.Combine(directory.FullName, "node_modules");
if (!Directory.Exists(nodeModulesPath))
{
var npmInstallResult = await RunNpmInstallAsync(directory, cancellationToken);
if (npmInstallResult != 0)
{
return (Success: false, Output: new OutputCollector(), Error: "Failed to install npm dependencies.", ChannelName: (string?)null, NeedsCodeGen: false);
}
}
// Build the AppHost server
var (buildSuccess, buildOutput, channelName) = await BuildAppHostServerAsync(appHostServerProject, packages, cancellationToken);
if (!buildSuccess)
{
return (Success: false, Output: buildOutput, Error: "Failed to build app host.", ChannelName: (string?)null, NeedsCodeGen: false);
}
return (Success: true, Output: buildOutput, Error: (string?)null, ChannelName: channelName, NeedsCodeGen: NeedsGeneration(directory.FullName, packages));
});
// Save the channel to settings.json if available
if (buildResult.ChannelName is not null)
{
var config = AspireJsonConfiguration.Load(directory.FullName) ?? new AspireJsonConfiguration();
config.Channel = buildResult.ChannelName;
config.Save(directory.FullName);
}
if (!buildResult.Success)
{
// Set OutputCollector so RunCommand can display errors
context.OutputCollector = buildResult.Output;
context.BuildCompletionSource?.TrySetResult(false);
return ExitCodeConstants.FailedToBuildArtifacts;
}
// Store output collector in context for exception handling by RunCommand
// This must be set BEFORE signaling build completion to avoid a race condition
context.OutputCollector = buildResult.Output;
// Signal that build/preparation is complete
context.BuildCompletionSource?.TrySetResult(true);
// Read launchSettings.json if it exists, or create defaults
var launchSettingsEnvVars = ReadLaunchSettingsEnvironmentVariables(directory) ?? new Dictionary<string, string>();
// Generate a backchannel socket path for CLI to connect to AppHost server
var backchannelSocketPath = GetBackchannelSocketPath();
// Pass the backchannel socket path to AppHost server so it opens a server for CLI communication
launchSettingsEnvVars[KnownConfigNames.UnixSocketPath] = backchannelSocketPath;
// Check if hot reload (watch mode) is enabled
var enableHotReload = _features.IsFeatureEnabled(KnownFeatures.DefaultWatchEnabled, defaultValue: false);
// Start the AppHost server process
var currentPid = Environment.ProcessId;
var (appHostServerProcess, appHostServerOutputCollector) = appHostServerProject.Run(socketPath, currentPid, launchSettingsEnvVars, debug: context.Debug);
// The backchannel completion source is the contract with RunCommand
// We signal this when the backchannel is ready, RunCommand uses it for UX
var backchannelCompletionSource = context.BackchannelCompletionSource ?? new TaskCompletionSource<IAppHostCliBackchannel>();
// Start connecting to the backchannel (for dashboard URLs, logs, etc.)
_ = StartBackchannelConnectionAsync(appHostServerProcess, backchannelSocketPath, backchannelCompletionSource, enableHotReload, cancellationToken);
// Give the server a moment to start
await Task.Delay(500, cancellationToken);
if (appHostServerProcess.HasExited)
{
_interactionService.DisplayLines(appHostServerOutputCollector.GetLines());
_interactionService.DisplayError("App host exited unexpectedly.");
return ExitCodeConstants.FailedToDotnetRunAppHost;
}
// Step 5: Generate TypeScript SDK via RPC if needed
if (buildResult.NeedsCodeGen)
{
await GenerateCodeViaRpcAsync(
directory.FullName,
socketPath,
packages,
cancellationToken);
}
// Step 6: Execute the TypeScript apphost
// Pass the socket path to the TypeScript process
var environmentVariables = new Dictionary<string, string>(context.EnvironmentVariables)
{
["REMOTE_APP_HOST_SOCKET_PATH"] = socketPath
};
// Start TypeScript apphost - it will connect to AppHost server, define resources
// When hot reload is enabled, use nodemon to watch for changes and restart
var pendingTypeScript = enableHotReload
? ExecuteWithNodemonAsync(appHostFile, directory, environmentVariables, cancellationToken)
: ExecuteTypeScriptAppHostAsync(appHostFile, directory, environmentVariables, cancellationToken);
// Wait for TypeScript to finish defining resources
var (typeScriptExitCode, typeScriptOutput) = await pendingTypeScript;
if (typeScriptExitCode != 0)
{
_logger.LogError("TypeScript apphost exited with code {ExitCode}", typeScriptExitCode);
// Display the output from TypeScript (same pattern as DotNetCliRunner)
_interactionService.DisplayLines(typeScriptOutput.GetLines());
// Signal failure to RunCommand so it doesn't hang waiting for the backchannel
var error = new InvalidOperationException("The TypeScript apphost failed.");
context.BackchannelCompletionSource?.TrySetException(error);
// Kill the AppHost server since TypeScript failed
if (!appHostServerProcess.HasExited)
{
try
{
appHostServerProcess.Kill(entireProcessTree: true);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error killing AppHost server process after TypeScript failure");
}
}
return typeScriptExitCode;
}
// In watch mode, wait for server to exit (Ctrl+C or orphan detection)
// In non-watch mode, kill the server now that TypeScript has exited
if (!enableHotReload && !appHostServerProcess.HasExited)
{
try
{
appHostServerProcess.Kill(entireProcessTree: true);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error killing AppHost server process");
}
}
await appHostServerProcess.WaitForExitAsync(cancellationToken);
return appHostServerProcess.ExitCode;
}
catch (OperationCanceledException)
{
// Signal that build/preparation failed so RunCommand doesn't hang waiting
context.BuildCompletionSource?.TrySetResult(false);
_interactionService.DisplayCancellationMessage();
return ExitCodeConstants.Success;
}
catch (Exception ex)
{
// Signal that build/preparation failed so RunCommand doesn't hang waiting
context.BuildCompletionSource?.TrySetResult(false);
_logger.LogError(ex, "Failed to run TypeScript AppHost");
_interactionService.DisplayError($"Failed to run TypeScript AppHost: {ex.Message}");
return ExitCodeConstants.FailedToDotnetRunAppHost;
}
}
private static IEnumerable<(string Name, string Version)> GetPackageReferences(DirectoryInfo directory)
{
// Always include the base Aspire.Hosting packages
yield return ("Aspire.Hosting", AppHostServerProject.AspireHostVersion);
yield return ("Aspire.Hosting.AppHost", AppHostServerProject.AspireHostVersion);
// Read additional packages from .aspire/settings.json
var aspireConfig = AspireJsonConfiguration.Load(directory.FullName);
if (aspireConfig?.Packages is not null)
{
foreach (var (packageName, version) in aspireConfig.Packages)
{
// Skip base packages as they're already included
if (string.Equals(packageName, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase) ||
string.Equals(packageName, "Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase))
{
continue;
}
yield return (packageName, version);
}
}
}
private Dictionary<string, string>? ReadLaunchSettingsEnvironmentVariables(DirectoryInfo directory)
{
// For TypeScript apphosts, look for apphost.run.json
// similar to how .NET single-file apphosts use apphost.run.json
var apphostRunPath = Path.Combine(directory.FullName, "apphost.run.json");
var launchSettingsPath = Path.Combine(directory.FullName, "Properties", "launchSettings.json");
var configPath = File.Exists(apphostRunPath) ? apphostRunPath : launchSettingsPath;
if (!File.Exists(configPath))
{
_logger.LogDebug("No apphost.run.json or launchSettings.json found in {Path}", directory.FullName);
return null;
}
try
{
var json = File.ReadAllText(configPath);
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("profiles", out var profiles))
{
return null;
}
// Try to find the 'https' profile first, then fall back to the first profile
JsonElement? profileElement = null;
if (profiles.TryGetProperty("https", out var httpsProfile))
{
profileElement = httpsProfile;
}
else
{
// Use the first profile
using var enumerator = profiles.EnumerateObject();
if (enumerator.MoveNext())
{
profileElement = enumerator.Current.Value;
}
}
if (profileElement == null)
{
return null;
}
var result = new Dictionary<string, string>();
// Read applicationUrl and convert to ASPNETCORE_URLS
if (profileElement.Value.TryGetProperty("applicationUrl", out var appUrl) &&
appUrl.ValueKind == JsonValueKind.String)
{
result["ASPNETCORE_URLS"] = appUrl.GetString()!;
}
// Read environment variables
if (profileElement.Value.TryGetProperty("environmentVariables", out var envVars))
{
foreach (var prop in envVars.EnumerateObject())
{
if (prop.Value.ValueKind == JsonValueKind.String)
{
result[prop.Name] = prop.Value.GetString()!;
}
}
}
if (result.Count == 0)
{
return null;
}
_logger.LogDebug("Read {Count} environment variables from apphost.run.json", result.Count);
return result;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read launchSettings.json");
return null;
}
}
private async Task<int> RunNpmInstallAsync(DirectoryInfo directory, CancellationToken cancellationToken)
{
var npmPath = FindNpmPath();
if (npmPath is null)
{
_interactionService.DisplayError("npm not found. Please install Node.js and ensure npm is in your PATH.");
return ExitCodeConstants.FailedToBuildArtifacts;
}
var startInfo = new ProcessStartInfo
{
FileName = npmPath,
Arguments = "install",
WorkingDirectory = directory.FullName,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process { StartInfo = startInfo };
process.Start();
await process.WaitForExitAsync(cancellationToken);
return process.ExitCode;
}
private async Task<(int ExitCode, OutputCollector Output)> ExecuteTypeScriptAppHostAsync(
FileInfo appHostFile,
DirectoryInfo directory,
IDictionary<string, string> environmentVariables,
CancellationToken cancellationToken,
string[]? additionalArgs = null)
{
// Try to find npx for running tsx directly, or use node if compiled
var npxPath = FindNpxPath();
// Build the additional arguments string
var argsString = additionalArgs is { Length: > 0 }
? " " + string.Join(" ", additionalArgs.Select(a => a.Contains(' ') ? $"\"{a}\"" : a))
: "";
ProcessStartInfo startInfo;
if (npxPath is not null)
{
// Use npx tsx to run TypeScript directly
startInfo = new ProcessStartInfo
{
FileName = npxPath,
Arguments = $"tsx \"{appHostFile.FullName}\"{argsString}",
WorkingDirectory = directory.FullName,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
}
else
{
// Fall back to node with compiled JavaScript
var nodePath = FindNodePath();
if (nodePath is null)
{
_interactionService.DisplayError("node not found. Please install Node.js and ensure it is in your PATH.");
return (ExitCodeConstants.FailedToDotnetRunAppHost, new OutputCollector());
}
var jsFile = Path.ChangeExtension(appHostFile.FullName, ".js");
var distJsFile = Path.Combine(directory.FullName, "dist", "apphost.js");
var targetFile = File.Exists(distJsFile) ? distJsFile : jsFile;
if (!File.Exists(targetFile))
{
_interactionService.DisplayError($"Compiled JavaScript file not found: {targetFile}");
_interactionService.DisplayMessage("info", "Try running 'npx tsc' to compile your TypeScript first.");
return (ExitCodeConstants.FailedToBuildArtifacts, new OutputCollector());
}
startInfo = new ProcessStartInfo
{
FileName = nodePath,
Arguments = $"\"{targetFile}\"{argsString}",
WorkingDirectory = directory.FullName,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
}
// Add environment variables
foreach (var (key, value) in environmentVariables)
{
startInfo.EnvironmentVariables[key] = value;
}
using var process = new Process { StartInfo = startInfo };
// Capture output for error reporting (same pattern as DotNetCliRunner)
var outputCollector = new OutputCollector();
process.OutputDataReceived += (sender, e) =>
{
if (e.Data is not null)
{
_logger.LogDebug("tsx({ProcessId}) {Identifier}: {Line}", process.Id, "stdout", e.Data);
outputCollector.AppendOutput(e.Data);
}
};
process.ErrorDataReceived += (sender, e) =>
{
if (e.Data is not null)
{
_logger.LogDebug("tsx({ProcessId}) {Identifier}: {Line}", process.Id, "stderr", e.Data);
outputCollector.AppendError(e.Data);
}
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync(cancellationToken);
return (process.ExitCode, outputCollector);
}
/// <summary>
/// Executes the TypeScript apphost using nodemon for hot reload.
/// Nodemon watches for file changes and automatically restarts the TypeScript process.
/// </summary>
private async Task<(int ExitCode, OutputCollector Output)> ExecuteWithNodemonAsync(
FileInfo appHostFile,
DirectoryInfo directory,
IDictionary<string, string> environmentVariables,
CancellationToken cancellationToken)
{
var npxPath = FindNpxPath();
if (npxPath is null)
{
_interactionService.DisplayError("npx not found. Please install Node.js and ensure npm is in your PATH.");
return (ExitCodeConstants.FailedToDotnetRunAppHost, new OutputCollector());
}
// Check if nodemon is installed locally before trying to use it
// This prevents npx from hanging while trying to install nodemon interactively
var nodemonPath = Path.Combine(directory.FullName, "node_modules", ".bin", "nodemon");
if (!File.Exists(nodemonPath))
{
_interactionService.DisplayError("nodemon is not installed. Please run 'npm install nodemon --save-dev' to enable hot reload.");
return (ExitCodeConstants.FailedToDotnetRunAppHost, new OutputCollector());
}
// Use nodemon to watch for file changes and restart the TypeScript apphost
// --watch . : Watch the current directory
// --ext ts,json : Watch .ts and .json files
// --ignore node_modules/ : Ignore node_modules
// --ignore .modules/ : Ignore generated modules
// --exec "npx tsx apphost.ts" : Execute the TypeScript file with tsx
var startInfo = new ProcessStartInfo
{
FileName = npxPath,
Arguments = $"nodemon --signal SIGTERM --watch . --ext ts,json --ignore node_modules/ --ignore .modules/ --exec \"npx tsx {appHostFile.Name}\"",
WorkingDirectory = directory.FullName,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
// Add environment variables
foreach (var (key, value) in environmentVariables)
{
startInfo.EnvironmentVariables[key] = value;
}
using var process = new Process { StartInfo = startInfo };
// Capture output for error reporting
var outputCollector = new OutputCollector();
process.OutputDataReceived += (sender, e) =>
{
if (e.Data is not null)
{
_logger.LogDebug("nodemon({ProcessId}) {Identifier}: {Line}", process.Id, "stdout", e.Data);
outputCollector.AppendOutput(e.Data);
}
};
process.ErrorDataReceived += (sender, e) =>
{
if (e.Data is not null)
{
_logger.LogDebug("nodemon({ProcessId}) {Identifier}: {Line}", process.Id, "stderr", e.Data);
outputCollector.AppendError(e.Data);
}
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync(cancellationToken);
return (process.ExitCode, outputCollector);
}
private static string? FindNpmPath()
{
return PathLookupHelper.FindFullPathFromPath("npm");
}
private static string? FindNpxPath()
{
return PathLookupHelper.FindFullPathFromPath("npx");
}
private static string? FindNodePath()
{
return PathLookupHelper.FindFullPathFromPath("node");
}
/// <inheritdoc />
public async Task<int> PublishAsync(PublishContext context, CancellationToken cancellationToken)
{
var appHostFile = context.AppHostFile;
var directory = appHostFile.Directory!;
_logger.LogDebug("Publishing TypeScript AppHost: {AppHostFile}", appHostFile.FullName);
try
{
// Step 1: Check if node_modules exists, run npm install if needed
var nodeModulesPath = Path.Combine(directory.FullName, "node_modules");
if (!Directory.Exists(nodeModulesPath))
{
var npmInstallResult = await RunNpmInstallAsync(directory, cancellationToken);
if (npmInstallResult != 0)
{
_interactionService.DisplayError("Failed to install npm dependencies.");
return ExitCodeConstants.FailedToBuildArtifacts;
}
}
// Step 2: Get package references and build AppHost server
var packages = GetPackageReferences(directory).ToList();
var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName);
var jsonRpcSocketPath = appHostServerProject.GetSocketPath();
// Build the AppHost server
var (buildSuccess, buildOutput, _) = await BuildAppHostServerAsync(appHostServerProject, packages, cancellationToken);
if (!buildSuccess)
{
// Set OutputCollector so PipelineCommandBase can display errors
context.OutputCollector = buildOutput;
// Signal the backchannel completion source so the caller doesn't wait forever
context.BackchannelCompletionSource?.TrySetException(
new InvalidOperationException("The app host build failed."));
return ExitCodeConstants.FailedToBuildArtifacts;
}
// Store output collector in context for exception handling
context.OutputCollector = buildOutput;
// Check if code generation is needed (we'll do it after server starts)
var needsCodeGen = NeedsGeneration(directory.FullName, packages);
// Read launchSettings.json if it exists
var launchSettingsEnvVars = ReadLaunchSettingsEnvironmentVariables(directory) ?? new Dictionary<string, string>();
// Generate a backchannel socket path for CLI to connect to AppHost server
var backchannelSocketPath = GetBackchannelSocketPath();
// Pass the backchannel socket path to AppHost server so it opens a server
launchSettingsEnvVars[KnownConfigNames.UnixSocketPath] = backchannelSocketPath;
// Start the AppHost server process (it opens the backchannel for progress reporting)
var currentPid = Environment.ProcessId;
var (appHostServerProcess, appHostServerOutputCollector) = appHostServerProject.Run(jsonRpcSocketPath, currentPid, launchSettingsEnvVars, debug: context.Debug);
// Start connecting to the backchannel
if (context.BackchannelCompletionSource is not null)
{
_ = StartBackchannelConnectionAsync(appHostServerProcess, backchannelSocketPath, context.BackchannelCompletionSource, enableHotReload: false, cancellationToken);
}
// Give the server a moment to start
await Task.Delay(500, cancellationToken);
// Step 3: Generate code via RPC now that server is running
if (needsCodeGen)
{
await GenerateCodeViaRpcAsync(
directory.FullName,
jsonRpcSocketPath,
packages,
cancellationToken);
}
if (appHostServerProcess.HasExited)
{
_interactionService.DisplayLines(appHostServerOutputCollector.GetLines());
_interactionService.DisplayError("App host exited unexpectedly.");
return ExitCodeConstants.FailedToDotnetRunAppHost;
}
// Pass the socket path to the TypeScript process
var environmentVariables = new Dictionary<string, string>(context.EnvironmentVariables)
{
["REMOTE_APP_HOST_SOCKET_PATH"] = jsonRpcSocketPath
};
// Execute the TypeScript apphost - this defines resources and triggers the publish
// Pass the publish arguments to the TypeScript app (e.g., --operation publish --step deploy)
var (typeScriptExitCode, typeScriptOutput) = await ExecuteTypeScriptAppHostAsync(appHostFile, directory, environmentVariables, cancellationToken, context.Arguments);
if (typeScriptExitCode != 0)
{
_logger.LogError("TypeScript apphost exited with code {ExitCode}", typeScriptExitCode);
// Display the output from TypeScript (same pattern as DotNetCliRunner)
_interactionService.DisplayLines(typeScriptOutput.GetLines());
// Signal failure so callers don't hang waiting for the backchannel
var error = new InvalidOperationException("The TypeScript apphost failed.");
context.BackchannelCompletionSource?.TrySetException(error);
// Kill the AppHost server since TypeScript failed
if (!appHostServerProcess.HasExited)
{
try
{
appHostServerProcess.Kill(entireProcessTree: true);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error killing AppHost server process after TypeScript failure");
}
}
return typeScriptExitCode;
}
// Kill the server after TypeScript exits
if (!appHostServerProcess.HasExited)
{
try
{
appHostServerProcess.Kill(entireProcessTree: true);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error killing AppHost server process");
}
}
await appHostServerProcess.WaitForExitAsync(cancellationToken);
return appHostServerProcess.ExitCode;
}
catch (OperationCanceledException)
{
_interactionService.DisplayCancellationMessage();
return ExitCodeConstants.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to publish TypeScript AppHost");
_interactionService.DisplayError($"Failed to publish TypeScript AppHost: {ex.Message}");
return ExitCodeConstants.FailedToDotnetRunAppHost;
}
}
/// <summary>
/// Gets the backchannel socket path for CLI communication.
/// </summary>
private static string GetBackchannelSocketPath()
{
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var aspireCliPath = Path.Combine(homeDirectory, ".aspire", "cli", "backchannels");
Directory.CreateDirectory(aspireCliPath);
var socketName = $"{Guid.NewGuid():N}.sock";
return Path.Combine(aspireCliPath, socketName);
}
/// <summary>
/// Starts connecting to the AppHost server's backchannel server.
/// </summary>
private async Task StartBackchannelConnectionAsync(
Process process,
string socketPath,
TaskCompletionSource<IAppHostCliBackchannel> backchannelCompletionSource,
bool enableHotReload,
CancellationToken cancellationToken)
{
const int ConnectionTimeoutSeconds = 60;
var startTime = DateTimeOffset.UtcNow;
var connectionAttempts = 0;
_logger.LogDebug("Starting backchannel connection to AppHost server at {SocketPath}", socketPath);
while (!cancellationToken.IsCancellationRequested)
{
try
{
_logger.LogTrace("Attempting to connect to AppHost server backchannel at {SocketPath} (attempt {Attempt})", socketPath, ++connectionAttempts);
// Pass enableHotReload as autoReconnect - the backchannel will handle reconnection internally
await _backchannel.ConnectAsync(socketPath, autoReconnect: enableHotReload, cancellationToken).ConfigureAwait(false);
backchannelCompletionSource.TrySetResult(_backchannel);
_logger.LogDebug("Connected to AppHost server backchannel at {SocketPath}", socketPath);
return;
}
catch (SocketException ex) when (process.HasExited && process.ExitCode != 0)
{
_logger.LogError("AppHost server process has exited. Unable to connect to backchannel at {SocketPath}", socketPath);
var backchannelException = new FailedToConnectBackchannelConnection($"AppHost server process has exited unexpectedly.", process, ex);
backchannelCompletionSource.TrySetException(backchannelException);
return;
}
catch (SocketException)
{
var waitingFor = DateTimeOffset.UtcNow - startTime;
// Timeout after ConnectionTimeoutSeconds - the AppHost server should have started by now
if (waitingFor > TimeSpan.FromSeconds(ConnectionTimeoutSeconds))
{
_logger.LogError("Timed out waiting for AppHost server to start after {Timeout} seconds", ConnectionTimeoutSeconds);
var timeoutException = new TimeoutException($"Timed out waiting for AppHost server to start after {ConnectionTimeoutSeconds} seconds. Check the debug logs for more details.");
backchannelCompletionSource.TrySetException(timeoutException);
return;
}
// Slow down polling after 10 seconds
if (waitingFor > TimeSpan.FromSeconds(10))
{
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
}
else
{
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to connect to AppHost server backchannel");
backchannelCompletionSource.TrySetException(ex);
return;
}
}
}
/// <inheritdoc />
public async Task<bool> AddPackageAsync(AddPackageContext context, CancellationToken cancellationToken)
{
var directory = context.AppHostFile.Directory;
if (directory is null)
{
return false;
}
// Update .aspire/settings.json with the new package
var config = AspireJsonConfiguration.Load(directory.FullName) ?? new AspireJsonConfiguration();
config.AddOrUpdatePackage(context.PackageId, context.PackageVersion);
config.Save(directory.FullName);
// Build and regenerate TypeScript SDK with the new package
await BuildAndGenerateSdkAsync(directory, cancellationToken);
return true;
}
/// <inheritdoc />
public async Task<UpdatePackagesResult> UpdatePackagesAsync(UpdatePackagesContext context, CancellationToken cancellationToken)
{
var directory = context.AppHostFile.Directory;
if (directory is null)
{
return new UpdatePackagesResult { UpdatesApplied = false };
}
// Read current packages from .aspire/settings.json
var config = AspireJsonConfiguration.Load(directory.FullName);
if (config?.Packages is null || config.Packages.Count == 0)
{
_interactionService.DisplayMessage("check_mark", UpdateCommandStrings.ProjectUpToDateMessage);
return new UpdatePackagesResult { UpdatesApplied = false };
}
// Find updates for each package
var updates = await _interactionService.ShowStatusAsync(
UpdateCommandStrings.AnalyzingProjectStatus,
async () =>
{
var packageUpdates = new List<(string PackageId, string CurrentVersion, string NewVersion)>();
foreach (var (packageId, currentVersion) in config.Packages)
{
try
{
var packages = await context.Channel.GetPackagesAsync(packageId, directory, cancellationToken);
var latestPackage = packages
.Where(p => SemVersion.TryParse(p.Version, SemVersionStyles.Strict, out _))
.OrderByDescending(p => SemVersion.Parse(p.Version, SemVersionStyles.Strict), SemVersion.PrecedenceComparer)
.FirstOrDefault();
if (latestPackage is not null && latestPackage.Version != currentVersion)
{
packageUpdates.Add((packageId, currentVersion, latestPackage.Version));
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to check for updates to package {PackageId}", packageId);
}
}
return packageUpdates;
});
if (updates.Count == 0)
{
_interactionService.DisplayMessage("check_mark", UpdateCommandStrings.ProjectUpToDateMessage);
return new UpdatePackagesResult { UpdatesApplied = false };
}
// Display pending updates
_interactionService.DisplayEmptyLine();
foreach (var (packageId, currentVersion, newVersion) in updates)
{
_interactionService.DisplayMessage("package", $"[bold yellow]{packageId}[/] [bold green]{currentVersion}[/] to [bold green]{newVersion}[/]");
}
_interactionService.DisplayEmptyLine();
// Confirm with user
if (!await _interactionService.ConfirmAsync(UpdateCommandStrings.PerformUpdatesPrompt, true, cancellationToken))
{
return new UpdatePackagesResult { UpdatesApplied = false };
}
// Apply updates to settings.json
foreach (var (packageId, _, newVersion) in updates)
{
config.AddOrUpdatePackage(packageId, newVersion);
}
config.Save(directory.FullName);
// Rebuild and regenerate TypeScript SDK with updated packages
_interactionService.DisplayEmptyLine();
_interactionService.DisplaySubtleMessage("Regenerating TypeScript SDK with updated packages...");
await BuildAndGenerateSdkAsync(directory, cancellationToken);
_interactionService.DisplayEmptyLine();
_interactionService.DisplaySuccess(UpdateCommandStrings.UpdateSuccessfulMessage);
return new UpdatePackagesResult { UpdatesApplied = true };
}
/// <inheritdoc />
public async Task<bool> CheckAndHandleRunningInstanceAsync(FileInfo appHostFile, DirectoryInfo homeDirectory, CancellationToken cancellationToken)
{
// For TypeScript projects, we use the AppHost server's path to compute the socket path
// The AppHost server is created in a subdirectory of the apphost.ts directory
var directory = appHostFile.Directory;
if (directory is null)
{
return true; // No directory, nothing to check
}
var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName);
var genericAppHostPath = appHostServerProject.GetProjectFilePath();
// Compute socket path based on the AppHost server project path
var auxiliarySocketPath = AppHostHelper.ComputeAuxiliarySocketPath(genericAppHostPath, homeDirectory.FullName);
// Check if the socket file exists
if (!File.Exists(auxiliarySocketPath))
{
return true; // No running instance, continue
}
// Stop the running instance
return await _runningInstanceManager.StopRunningInstanceAsync(auxiliarySocketPath, cancellationToken);
}
/// <summary>
/// Checks if code generation is needed based on the current state.
/// </summary>
private bool NeedsGeneration(string appPath, IEnumerable<(string PackageId, string Version)> packages)
{
// In dev mode (ASPIRE_REPO_ROOT set), always regenerate to pick up code changes
if (!string.IsNullOrEmpty(_configuration["ASPIRE_REPO_ROOT"]))
{
_logger.LogDebug("Dev mode detected (ASPIRE_REPO_ROOT set), skipping generation cache");
return true;
}
return CheckNeedsGeneration(appPath, packages.ToList());
}
/// <summary>
/// Checks if code generation is needed by comparing the hash of current packages
/// with the stored hash from previous generation.
/// </summary>
private static bool CheckNeedsGeneration(string appPath, List<(string PackageId, string Version)> packages)
{
var generatedPath = Path.Combine(appPath, GeneratedFolderName);
var hashPath = Path.Combine(generatedPath, ".codegen-hash");
// If hash file doesn't exist, generation is needed
if (!File.Exists(hashPath))
{
return true;
}
// Compare stored hash with current packages hash
var storedHash = File.ReadAllText(hashPath).Trim();
var currentHash = ComputePackagesHash(packages);
return !string.Equals(storedHash, currentHash, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Generates TypeScript SDK code by calling the AppHost server's generateCode RPC method.
/// </summary>
private async Task GenerateCodeViaRpcAsync(
string appPath,
string socketPath,
IEnumerable<(string PackageId, string Version)> packages,
CancellationToken cancellationToken)
{
var packagesList = packages.ToList();
_logger.LogDebug("Generating TypeScript code via RPC for {Count} packages", packagesList.Count);
// Connect to the AppHost server using platform-appropriate transport
Stream stream;
var connected = false;
var startTime = DateTimeOffset.UtcNow;
if (OperatingSystem.IsWindows())
{
// On Windows, use named pipes (matches JsonRpcServer behavior)
var pipeClient = new NamedPipeClientStream(".", socketPath, PipeDirection.InOut, PipeOptions.Asynchronous);
// Wait for server to be ready (retry for up to 30 seconds)
while (!connected && (DateTimeOffset.UtcNow - startTime) < TimeSpan.FromSeconds(30))
{
try
{
await pipeClient.ConnectAsync(cancellationToken).ConfigureAwait(false);
connected = true;
}
catch (TimeoutException)
{
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
catch (IOException)
{
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
}
if (!connected)
{
pipeClient.Dispose();
throw new InvalidOperationException($"Failed to connect to AppHost server at {socketPath}");
}
stream = pipeClient;
}
else
{
// On Unix/macOS, use Unix domain sockets (matches JsonRpcServer behavior)
var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
var endpoint = new UnixDomainSocketEndPoint(socketPath);
// Wait for server to be ready (retry for up to 30 seconds)
while (!connected && (DateTimeOffset.UtcNow - startTime) < TimeSpan.FromSeconds(30))
{
try
{
await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false);
connected = true;
}
catch (SocketException)
{
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
}
if (!connected)
{
socket.Dispose();
throw new InvalidOperationException($"Failed to connect to AppHost server at {socketPath}");
}
stream = new NetworkStream(socket, ownsSocket: true);
}
using (stream)
{
// Set up JSON-RPC connection with AOT-compatible JSON formatter
var formatter = Backchannel.BackchannelJsonSerializerContext.CreateRpcMessageFormatter();
var handler = new StreamJsonRpc.HeaderDelimitedMessageHandler(stream, stream, formatter);
using var jsonRpc = new StreamJsonRpc.JsonRpc(handler);
jsonRpc.StartListening();
// Call generateCode RPC method
_logger.LogDebug("Calling generateCode RPC method");
var files = await jsonRpc.InvokeWithCancellationAsync<Dictionary<string, string>>("generateCode", ["TypeScript"], cancellationToken);
// Write generated files to the output directory
var outputPath = Path.Combine(appPath, GeneratedFolderName);
Directory.CreateDirectory(outputPath);
foreach (var (fileName, content) in files)
{
var filePath = Path.Combine(outputPath, fileName);
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
await File.WriteAllTextAsync(filePath, content, cancellationToken);
}
// Write generation hash for caching
SaveGenerationHash(outputPath, packagesList);
_logger.LogInformation("Generated {Count} TypeScript files in {Path}",
files.Count, outputPath);
}
}
/// <summary>
/// Saves a hash of the packages to avoid regenerating code unnecessarily.
/// </summary>
private static void SaveGenerationHash(string generatedPath, List<(string PackageId, string Version)> packages)
{
var hashPath = Path.Combine(generatedPath, ".codegen-hash");
var hash = ComputePackagesHash(packages);
File.WriteAllText(hashPath, hash);
}
/// <summary>
/// Computes a hash of the package list for caching purposes.
/// </summary>
private static string ComputePackagesHash(List<(string PackageId, string Version)> packages)
{
var sb = new System.Text.StringBuilder();
foreach (var (packageId, version) in packages.OrderBy(p => p.PackageId))
{
sb.Append(packageId);
sb.Append(':');
sb.Append(version);
sb.Append(';');
}
var bytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(sb.ToString()));
return Convert.ToHexString(bytes);
}
}
|