diff --git a/docs/extensibility/custom-non-container-resource.md b/docs/extensibility/custom-non-container-resource.md new file mode 100644 index 0000000000..86a48bcc72 --- /dev/null +++ b/docs/extensibility/custom-non-container-resource.md @@ -0,0 +1,131 @@ +--- +title: Create non-container custom resources +description: Learn how to create custom .NET Aspire resources that don't rely on containers using lifecycle hooks and dashboard integration. +ms.date: 06/25/2025 +ms.topic: how-to +--- + +# Create non-container custom resources + +While many .NET Aspire resources are container-based, you can also create custom resources that run in-process or manage external services without containers. This article shows how to build a non-container custom resource that integrates with the Aspire dashboard using lifecycle hooks, status notifications, and logging. + +## When to use non-container resources + +Before creating a custom resource, consider whether your scenario might be better served by simpler approaches: + +- **Connection strings only**: If you just need to connect to an external service, might suffice. +- **Configuration values**: For simple configuration, might be enough. + +Custom non-container resources are valuable when you need: + +- Dashboard integration with status updates and logs. +- Lifecycle management (starting/stopping services). +- In-process services that benefit from Aspire's orchestration. +- External resource management with rich feedback. + +Examples include: + +- In-process HTTP proxies or middleware. +- Cloud service provisioning and management. +- External API integrations with health monitoring. +- Background services that need dashboard visibility. + +## Key components + +Non-container custom resources use these key Aspire services: + +- ****: Hook into app startup/shutdown. For more information, see [App Host life cycle events](../app-host/eventing.md#app-host-life-cycle-events). +- ****: Standard .NET logging that appears in console and dashboard + +> [!NOTE] +> Advanced dashboard integration is possible using services like `ResourceNotificationService` and `ResourceLoggerService` for real-time status updates and log streaming. These APIs provide richer dashboard experiences but require more complex implementation. + +## Example: HTTP proxy resource + +This example creates an in-process HTTP proxy resource that demonstrates the core concepts of lifecycle management and logging integration with the Aspire dashboard. + +### Define the resource + +First, create the resource class: + +:::code language="csharp" source="snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyResource.cs"::: + +The resource implements and includes properties for the proxy configuration. + +### Create the extension method + +Next, create the builder extension: + +:::code language="csharp" source="snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyResourceBuilderExtensions.cs"::: + +This extension method adds the resource to the application model and configures an HTTP endpoint. + +### Implement lifecycle management + +Create a lifecycle hook to manage the proxy: + +:::code language="csharp" source="snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyLifecycleHook.cs"::: + +The lifecycle hook: + +1. **Manages lifecycle**: Starts services when resources are created. +1. **Integrates logging**: Uses standard .NET logging that appears in the Aspire dashboard. +1. **Handles background tasks**: Runs long-running services in background tasks. +1. **Provides resource management**: Manages resources like HTTP listeners and cleanup. + +### Register the lifecycle hook + +The extension method automatically registers the lifecycle hook: + +:::code language="csharp" source="snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyResourceBuilderExtensions.cs"::: + +### Use the resource + +Now you can use the proxy in your app host: + +:::code language="csharp" source="snippets/HttpProxyResource/HttpProxySample.AppHost/Program.cs"::: + +## Dashboard integration + +Non-container resources integrate with the Aspire dashboard through multiple channels, providing real-time visibility into your resource's status, logs, and performance. + +### Standard logging + +All .NET logging automatically appears in the Aspire dashboard. Use structured logging for better searchability and filtering: + +```csharp +_logger.LogInformation("Starting HTTP proxy {ResourceName} -> {TargetUrl}", + resource.Name, resource.TargetUrl); +_logger.LogError(ex, "Failed to start HTTP proxy {ResourceName}", resource.Name); +``` + +### Advanced dashboard features + +For more sophisticated dashboard integration, you can use: + +- **Status notifications**: Update resource state and properties in real-time using advanced Aspire hosting APIs +- **Log streaming**: Send structured logs directly to the dashboard using specialized services +- **Health monitoring**: Report resource health and performance metrics. For more information, see [Health checks in .NET Aspire](../fundamentals/health-checks.md) + +For more information about the dashboard's capabilities, see [Explore the .NET Aspire dashboard](../fundamentals/dashboard/explore.md). + +These advanced features require additional Aspire hosting APIs and more complex implementation patterns. + +## Best practices + +When creating non-container resources: + +1. **Resource cleanup**: Always implement proper disposal in lifecycle hooks +1. **Error handling**: Catch and log exceptions, update status appropriately +1. **Status updates**: Provide meaningful status information to users +1. **Performance**: Avoid blocking operations in lifecycle methods +1. **Dependencies**: Use dependency injection for required services + +## Summary + +Non-container custom resources extend .NET Aspire beyond containers to include in-process services and external resource management. By implementing lifecycle hooks and integrating with the dashboard through status notifications and logging, you can create rich development experiences for any type of resource your application needs. + +## Next steps + +> [!div class="nextstepaction"] +> [Create custom .NET Aspire client integrations](custom-client-integration.md) diff --git a/docs/extensibility/snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxy.Hosting.csproj b/docs/extensibility/snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxy.Hosting.csproj new file mode 100644 index 0000000000..98bc1fd455 --- /dev/null +++ b/docs/extensibility/snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxy.Hosting.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + \ No newline at end of file diff --git a/docs/extensibility/snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyLifecycleHook.cs b/docs/extensibility/snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyLifecycleHook.cs new file mode 100644 index 0000000000..8947c5241c --- /dev/null +++ b/docs/extensibility/snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyLifecycleHook.cs @@ -0,0 +1,102 @@ +using System.Net; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.Logging; + +namespace HttpProxy.Hosting; + +/// +/// Lifecycle hook that manages HTTP proxy resources. +/// +public class HttpProxyLifecycleHook : IDistributedApplicationLifecycleHook +{ + private readonly ILogger _logger; + private readonly Dictionary _listeners = new(); + + public HttpProxyLifecycleHook(ILogger logger) + { + _logger = logger; + } + + public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task AfterResourcesCreatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + { + // Find and start HTTP proxy resources + var proxyResources = appModel.Resources.OfType(); + + foreach (var resource in proxyResources) + { + StartProxy(resource); + } + + return Task.CompletedTask; + } + + private void StartProxy(HttpProxyResource resource) + { + try + { + _logger.LogInformation("Starting HTTP proxy {ResourceName} -> {TargetUrl}", + resource.Name, resource.TargetUrl); + + // Create and start HTTP listener on a dynamic port + var listener = new HttpListener(); + listener.Prefixes.Add("http://localhost:0/"); // Use system-assigned port + listener.Start(); + + _listeners[resource.Name] = listener; + + // Start processing requests in the background + _ = Task.Run(() => ProcessRequests(resource, listener)); + + _logger.LogInformation("HTTP proxy {ResourceName} started successfully", resource.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start HTTP proxy {ResourceName}", resource.Name); + } + } + + private async Task ProcessRequests(HttpProxyResource resource, HttpListener listener) + { + var requestCount = 0; + + while (listener.IsListening) + { + try + { + var context = await listener.GetContextAsync(); + requestCount++; + + _logger.LogInformation("Proxy {ResourceName} handling request {RequestCount}: {Method} {Path}", + resource.Name, requestCount, context.Request.HttpMethod, context.Request.Url?.PathAndQuery); + + // Simple response for demonstration + var response = context.Response; + response.StatusCode = 200; + var responseString = $"Proxy {resource.Name} would forward to {resource.TargetUrl}"; + var buffer = System.Text.Encoding.UTF8.GetBytes(responseString); + await response.OutputStream.WriteAsync(buffer); + response.Close(); + } + catch (HttpListenerException) + { + // Listener was stopped + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing request in proxy {ResourceName}", resource.Name); + } + } + } +} \ No newline at end of file diff --git a/docs/extensibility/snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyResource.cs b/docs/extensibility/snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyResource.cs new file mode 100644 index 0000000000..88e2d1ecb8 --- /dev/null +++ b/docs/extensibility/snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyResource.cs @@ -0,0 +1,21 @@ +using Aspire.Hosting.ApplicationModel; + +namespace HttpProxy.Hosting; + +/// +/// Represents an HTTP proxy resource that forwards requests to a target URL. +/// +/// The name of the resource. +/// The target URL to proxy requests to. +public class HttpProxyResource(string name, string targetUrl) : Resource(name), IResourceWithEndpoints +{ + /// + /// Gets the target URL that requests will be proxied to. + /// + public string TargetUrl { get; } = targetUrl; + + /// + /// Gets the name of the HTTP endpoint. + /// + public const string HttpEndpointName = "http"; +} \ No newline at end of file diff --git a/docs/extensibility/snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyResourceBuilderExtensions.cs b/docs/extensibility/snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyResourceBuilderExtensions.cs new file mode 100644 index 0000000000..7f67b6f263 --- /dev/null +++ b/docs/extensibility/snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyResourceBuilderExtensions.cs @@ -0,0 +1,36 @@ +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aspire.Hosting; + +/// +/// Extension methods for adding HTTP proxy resources to the application model. +/// +public static class HttpProxyResourceBuilderExtensions +{ + /// + /// Adds an HTTP proxy resource to the application model. + /// + /// The distributed application builder. + /// The name of the resource. + /// The target URL to proxy requests to. + /// The port to listen on (optional). + /// A resource builder for the HTTP proxy resource. + public static IResourceBuilder AddHttpProxy( + this IDistributedApplicationBuilder builder, + string name, + string targetUrl, + int? port = null) + { + var resource = new HttpProxy.Hosting.HttpProxyResource(name, targetUrl); + + // Register the lifecycle hook for this resource type + builder.Services.TryAddSingleton(); + builder.Services.AddLifecycleHook(); + + return builder.AddResource(resource) + .WithHttpEndpoint(port: port, name: HttpProxy.Hosting.HttpProxyResource.HttpEndpointName); + } +} \ No newline at end of file diff --git a/docs/extensibility/snippets/HttpProxyResource/HttpProxySample.AppHost/HttpProxySample.AppHost.csproj b/docs/extensibility/snippets/HttpProxyResource/HttpProxySample.AppHost/HttpProxySample.AppHost.csproj new file mode 100644 index 0000000000..4b07d466a2 --- /dev/null +++ b/docs/extensibility/snippets/HttpProxyResource/HttpProxySample.AppHost/HttpProxySample.AppHost.csproj @@ -0,0 +1,16 @@ + + + + Exe + net9.0 + enable + enable + HttpProxySample-AppHost-b8d82d85-6a93-4b67-9a70-77fe8d9e6c5c + + + + + + + + \ No newline at end of file diff --git a/docs/extensibility/snippets/HttpProxyResource/HttpProxySample.AppHost/Program.cs b/docs/extensibility/snippets/HttpProxyResource/HttpProxySample.AppHost/Program.cs new file mode 100644 index 0000000000..78de14ef71 --- /dev/null +++ b/docs/extensibility/snippets/HttpProxyResource/HttpProxySample.AppHost/Program.cs @@ -0,0 +1,12 @@ +using Aspire.Hosting; + +var builder = DistributedApplication.CreateBuilder(args); + +// Add an HTTP proxy that forwards requests to an external API +var proxy = builder.AddHttpProxy("api-proxy", "https://jsonplaceholder.typicode.com", port: 5100); + +// Add a web project that can use the proxy +var webapp = builder.AddProject("webapp") + .WithReference(proxy); + +builder.Build().Run(); \ No newline at end of file diff --git a/docs/extensibility/snippets/HttpProxyResource/HttpProxySample.slnx b/docs/extensibility/snippets/HttpProxyResource/HttpProxySample.slnx new file mode 100644 index 0000000000..f3daa2d456 --- /dev/null +++ b/docs/extensibility/snippets/HttpProxyResource/HttpProxySample.slnx @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/docs/toc.yml b/docs/toc.yml index 4e4bdefe24..5c6f92c5d0 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -75,6 +75,11 @@ items: - name: Persist data using volumes displayName: volumes,persist data href: fundamentals/persist-data-volumes.md + - name: Custom resources + items: + - name: Create non-container custom resources + displayName: resources,extensibility,hosting,integrations,lifecycle,dashboard + href: extensibility/custom-non-container-resource.md - name: Dashboard items: