// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREDOCKERFILEBUILDER001
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIRECERTIFICATES001
#pragma warning disable ASPIREEXTENSION001
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.ApplicationModel.Docker;
using Aspire.Hosting.JavaScript;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Aspire.Hosting;
///
/// Provides extension methods for adding JavaScript applications to an .
///
public static class JavaScriptHostingExtensions
{
private const string BrowserCapability = "browser";
private const string DefaultNodeVersion = "22";
private const string DefaultJavaScriptRunScriptName = "dev";
private const string DefaultYarpImage = Yarp.YarpContainerImageTags.Registry + "/" + Yarp.YarpContainerImageTags.Image + ":" + Yarp.YarpContainerImageTags.Tag;
// This is the order of config files that Vite will look for by default
// See https://github.com/vitejs/vite/blob/main/packages/vite/src/node/constants.ts#L97
private static readonly string[] s_defaultConfigFiles = ["vite.config.js", "vite.config.mjs", "vite.config.ts", "vite.config.cjs", "vite.config.mts", "vite.config.cts"];
// The token to replace with the relative path to the user's Vite config file
private const string AspireViteRelativeConfigToken = "%%ASPIRE_VITE_RELATIVE_CONFIG_PATH%%";
// The token to replace with the absolute path to the original Vite config file
private const string AspireViteAbsoluteConfigToken = "%%ASPIRE_VITE_ABSOLUTE_CONFIG_PATH%%";
// A template Vite config that loads an existing config provides a default https configuration if one isn't present
// Uses environment variables to configure a TLS certificate in PFX format and its password if specified
// The value of %%ASPIRE_VITE_RELATIVE_CONFIG_PATH%% is replaced with the path to the user's actual Vite config file at runtime
// Vite only supports module style config files, so we don't have to handle commonjs style imports or exports here
private const string AspireViteConfig = """
import { defineConfig } from 'vite'
import config from '%%ASPIRE_VITE_RELATIVE_CONFIG_PATH%%'
console.log('Applying Aspire specific Vite configuration for HTTPS support.')
console.log('Found original Vite configuration at "%%ASPIRE_VITE_ABSOLUTE_CONFIG_PATH%%"')
const aspireHttpsConfig = process.env['TLS_CONFIG_PFX'] ? {
pfx: process.env['TLS_CONFIG_PFX'],
passphrase: process.env['TLS_CONFIG_PASSWORD'],
} : undefined
const wrapConfig = (innerConfig) => ({
...innerConfig,
server: {
...innerConfig.server,
https: innerConfig.server?.https ?? aspireHttpsConfig,
}
})
let finalConfig = config
try {
if (typeof config === 'function') {
finalConfig = defineConfig((cfg) => {
let innerConfig = config(cfg)
return wrapConfig(innerConfig)
});
} else if (typeof config === 'object' && config !== null) {
let innerConfig = config
finalConfig = defineConfig(wrapConfig(innerConfig))
} else {
console.warn('Unexpected Vite config format. Falling back to original configuration without Aspire HTTPS modifications.')
finalConfig = config
}
} catch {
console.warn('Error applying Aspire Vite configuration. Falling back to original configuration without Aspire HTTPS modifications.')
finalConfig = config
}
export default finalConfig
""";
///
/// Adds a node application to the application model. Node should be available on the PATH.
///
/// The to add the resource to.
/// The name of the resource.
/// The path to the directory containing the node application.
/// The path to the script relative to the app directory to run.
/// A reference to the .
/// The resource builder.
///
/// This method executes a Node script directly using node script.js. If you want to use a package manager
/// you can add one and configure the install and run scripts using the provided extension methods.
///
/// If the application directory contains a package.json file, npm will be added as the default package manager.
///
///
/// Add a Node app to the application model using yarn and 'yarn run dev' for running during development:
///
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddNodeApp("frontend", "../frontend", "app.js")
/// .WithYarn()
/// .WithRunScript("dev");
///
/// builder.Build().Run();
///
///
[AspireExport]
public static IResourceBuilder AddNodeApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string scriptPath)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(scriptPath);
appDirectory = Path.GetFullPath(appDirectory, builder.AppHostDirectory);
var resource = new NodeAppResource(name, "node", appDirectory);
var resourceBuilder = builder.AddResource(resource)
.WithNodeDefaults()
.WithArgs(c =>
{
// If the JavaScriptRunScriptAnnotation is present, use that to run the app
if (c.Resource.TryGetLastAnnotation(out var runCommand) &&
c.Resource.TryGetLastAnnotation(out var packageManager))
{
if (!string.IsNullOrEmpty(packageManager.ScriptCommand))
{
c.Args.Add(packageManager.ScriptCommand);
}
c.Args.Add(runCommand.ScriptName);
foreach (var arg in runCommand.Args)
{
c.Args.Add(arg);
}
}
else
{
c.Args.Add(scriptPath);
}
})
.WithIconName("CodeJsRectangle")
.PublishAsDockerFile(c =>
{
// Only generate a Dockerfile if one doesn't already exist in the app directory
if (File.Exists(Path.Combine(resource.WorkingDirectory, "Dockerfile")))
{
return;
}
c.WithDockerfileBuilder(resource.WorkingDirectory, dockerfileContext =>
{
var defaultBaseImage = new Lazy(() => GetDefaultBaseImage(appDirectory, "alpine", dockerfileContext.Services));
// Get custom base image from annotation, if present
dockerfileContext.Resource.TryGetLastAnnotation(out var baseImageAnnotation);
var baseBuildImage = baseImageAnnotation?.BuildImage ?? defaultBaseImage.Value;
var builderStage = dockerfileContext.Builder
.From(baseBuildImage, "build")
.EmptyLine()
.WorkDir("/app");
if (resource.TryGetLastAnnotation(out var packageManager))
{
// Initialize the Docker build stage with package manager-specific setup commands.
// This allows package managers to add prerequisite commands (e.g., enabling pnpm via corepack)
// before package installation and build steps.
packageManager.InitializeDockerBuildStage?.Invoke(builderStage);
var copiedAllSource = false;
if (resource.TryGetLastAnnotation(out var installCommand))
{
// Copy package files first for better layer caching
if (packageManager.PackageFilesPatterns.Count > 0)
{
foreach (var packageFilePattern in packageManager.PackageFilesPatterns)
{
builderStage.Copy(packageFilePattern.Source, packageFilePattern.Destination);
}
}
else
{
builderStage.Copy(".", ".");
copiedAllSource = true;
}
builderStage.AddInstallCommand(packageManager, installCommand);
}
if (!copiedAllSource)
{
// Copy application source code after dependencies are installed
builderStage.Copy(".", ".");
}
if (resource.TryGetLastAnnotation(out var buildCommand))
{
var commandArgs = new List() { packageManager.ExecutableName };
if (!string.IsNullOrEmpty(packageManager.ScriptCommand))
{
commandArgs.Add(packageManager.ScriptCommand);
}
commandArgs.Add(buildCommand.ScriptName);
commandArgs.AddRange(buildCommand.Args);
builderStage.EmptyLine()
.Run(string.Join(' ', commandArgs));
}
}
else
{
// No package manager, just copy everything
builderStage.Copy(".", ".");
}
var logger = dockerfileContext.Services.GetService>();
dockerfileContext.Builder.AddContainerFilesStages(dockerfileContext.Resource, logger);
var baseRuntimeImage = baseImageAnnotation?.RuntimeImage ?? defaultBaseImage.Value;
var runtimeBuilder = dockerfileContext.Builder
.From(baseRuntimeImage, "runtime")
.EmptyLine()
.WorkDir("/app")
.CopyFrom("build", "/app", "/app")
.AddContainerFiles(dockerfileContext.Resource, "/app", logger)
.EmptyLine()
.Env("NODE_ENV", "production")
.EmptyLine()
.User("node")
.EmptyLine()
.Entrypoint([resource.Command, scriptPath]);
});
});
// Configure pipeline to ensure container file sources are built first
resourceBuilder.WithPipelineConfiguration(context =>
{
if (resourceBuilder.Resource.TryGetAnnotationsOfType(out var containerFilesAnnotations))
{
var buildSteps = context.GetSteps(resourceBuilder.Resource, WellKnownPipelineTags.BuildCompute);
foreach (var containerFile in containerFilesAnnotations)
{
buildSteps.DependsOn(context.GetSteps(containerFile.Source, WellKnownPipelineTags.BuildCompute));
}
}
});
if (File.Exists(Path.Combine(appDirectory, "package.json")))
{
// Automatically add npm as the package manager if a package.json file exists
resourceBuilder.WithNpm();
}
resourceBuilder.WithVSCodeDebugging(scriptPath);
if (builder.ExecutionContext.IsRunMode)
{
builder.OnBeforeStart((_, _) =>
{
// set the command to the package manager executable if the JavaScriptRunScriptAnnotation is present
if (resourceBuilder.Resource.TryGetLastAnnotation(out _) &&
resourceBuilder.Resource.TryGetLastAnnotation(out var packageManager))
{
resourceBuilder.WithCommand(packageManager.ExecutableName);
}
return Task.CompletedTask;
});
}
return resourceBuilder;
}
private static IResourceBuilder WithNodeDefaults(this IResourceBuilder builder) where TResource : JavaScriptAppResource =>
builder.WithOtlpExporter()
.WithRequiredCommand("node", "https://nodejs.org/en/download/")
.WithEnvironment("NODE_ENV", builder.ApplicationBuilder.Environment.IsDevelopment() ? "development" : "production")
.WithCertificateTrustConfiguration((ctx) =>
{
if (ctx.Scope == CertificateTrustScope.Append)
{
ctx.EnvironmentVariables["NODE_EXTRA_CA_CERTS"] = ctx.CertificateBundlePath;
}
else
{
if (ctx.EnvironmentVariables.TryGetValue("NODE_OPTIONS", out var existingOptionsObj))
{
ctx.EnvironmentVariables["NODE_OPTIONS"] = existingOptionsObj switch
{
// Attempt to append to existing NODE_OPTIONS if possible, otherwise overwrite
string s when !string.IsNullOrEmpty(s) => $"{s} --use-openssl-ca",
ReferenceExpression re => ReferenceExpression.Create($"{re} --use-openssl-ca"),
_ => "--use-openssl-ca",
};
}
else
{
ctx.EnvironmentVariables["NODE_OPTIONS"] = "--use-openssl-ca";
}
}
return Task.CompletedTask;
});
// The default Docker image used for AddBunApp build and runtime stages.
// Pinned to the major version tag to keep generated Dockerfiles deterministic
// while still picking up patch updates. The image provides a non-root `bun` user.
private const string DefaultBunImage = "oven/bun:1";
// Default .dockerignore content emitted alongside the generated Bun Dockerfile using
// BuildKit's per-Dockerfile ignore convention. The runtime stage uses `COPY . .` from the
// build context so an ignore file is required to keep local node_modules, .git, dotenv
// files, etc. out of the published image. Mirrors the recommendation at
// https://bun.com/guides/ecosystem/docker.
private const string DefaultBunBuildContextIgnoreContent = """
# Generated by Aspire. Author /.dockerignore to override.
node_modules
.git
.gitignore
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.env
.env.*
.aspire
aspire-output
Dockerfile
Dockerfile.*
*.Dockerfile.dockerignore
.dockerignore
*.tsbuildinfo
""";
///
/// Adds a Bun application to the application model. Bun should be available on the PATH.
///
/// The to add the resource to.
/// The name of the resource.
/// The path to the directory containing the Bun application.
/// The path to the script (for example, server.ts) relative to to run.
/// A reference to the .
/// The resource builder.
///
/// This method executes the script directly using bun <script>. Bun natively runs JavaScript and TypeScript
/// files so no transpile step is required.
///
/// If the application directory contains a package.json file, Bun will be added as the default package manager.
/// When publishing to a container, the default base image is oven/bun:1 for both the build and runtime stages.
///
///
/// Add a Bun app to the application model:
///
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddBunApp("api", "../api", "server.ts");
///
/// builder.Build().Run();
///
///
[AspireExport]
public static IResourceBuilder AddBunApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string scriptPath)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(scriptPath);
appDirectory = Path.GetFullPath(appDirectory, builder.AppHostDirectory);
var resource = new BunAppResource(name, "bun", appDirectory);
var resourceBuilder = builder.AddResource(resource)
.WithBunDefaults()
.WithArgs(c =>
{
// If the JavaScriptRunScriptAnnotation is present, use that to run the app
if (c.Resource.TryGetLastAnnotation(out var runCommand) &&
c.Resource.TryGetLastAnnotation(out var packageManager))
{
if (!string.IsNullOrEmpty(packageManager.ScriptCommand))
{
c.Args.Add(packageManager.ScriptCommand);
}
c.Args.Add(runCommand.ScriptName);
foreach (var arg in runCommand.Args)
{
c.Args.Add(arg);
}
}
else
{
c.Args.Add(scriptPath);
}
})
.WithIconName("CodeJsRectangle")
.PublishAsDockerFile(c =>
{
// Only generate a Dockerfile if one doesn't already exist in the app directory
if (File.Exists(Path.Combine(resource.WorkingDirectory, "Dockerfile")))
{
return;
}
c.WithDockerfileBuilder(resource.WorkingDirectory, dockerfileContext =>
{
// Get custom base image from annotation, if present
dockerfileContext.Resource.TryGetLastAnnotation(out var baseImageAnnotation);
// Provide a default .dockerignore that publishers emit alongside the generated
// Dockerfile using BuildKit's per-Dockerfile ignore convention
// (.dockerignore). The runtime stage below copies source
// directly from the build context (`COPY . .`), so without an ignore file the
// user's local node_modules, .git, etc. would leak into the build context and
// into the image. Matches the recommendation at
// https://bun.com/guides/ecosystem/docker. The annotation lookup is guarded
// because WithDockerfileBuilder always adds a DockerfileBuildAnnotation, but
// we want to remain robust if a future refactor changes that.
if (dockerfileContext.Resource.TryGetLastAnnotation(out var dockerfileBuildAnnotation))
{
dockerfileBuildAnnotation.BuildContextIgnoreContent ??= DefaultBunBuildContextIgnoreContent;
}
// Bun ships its own runtime, so both stages default to the same Bun image rather than
// using a node-based image as in AddNodeApp.
var baseBuildImage = baseImageAnnotation?.BuildImage ?? DefaultBunImage;
var builderStage = dockerfileContext.Builder
.From(baseBuildImage, "build")
.EmptyLine()
.WorkDir("/app");
if (resource.TryGetLastAnnotation(out var packageManager))
{
// Initialize the Docker build stage with package manager-specific setup commands.
packageManager.InitializeDockerBuildStage?.Invoke(builderStage);
var copiedAllSource = false;
if (resource.TryGetLastAnnotation(out var installCommand))
{
// Copy package files first for better layer caching
if (packageManager.PackageFilesPatterns.Count > 0)
{
foreach (var packageFilePattern in packageManager.PackageFilesPatterns)
{
builderStage.Copy(packageFilePattern.Source, packageFilePattern.Destination);
}
}
else
{
builderStage.Copy(".", ".");
copiedAllSource = true;
}
builderStage.AddInstallCommand(packageManager, installCommand);
}
if (!copiedAllSource)
{
builderStage.Copy(".", ".");
}
if (resource.TryGetLastAnnotation(out var buildCommand))
{
var commandArgs = new List() { packageManager.ExecutableName };
if (!string.IsNullOrEmpty(packageManager.ScriptCommand))
{
commandArgs.Add(packageManager.ScriptCommand);
}
commandArgs.Add(buildCommand.ScriptName);
commandArgs.AddRange(buildCommand.Args);
builderStage.EmptyLine()
.Run(string.Join(' ', commandArgs));
}
}
else
{
// No package manager, just copy everything
builderStage.Copy(".", ".");
}
var logger = dockerfileContext.Services.GetService>();
dockerfileContext.Builder.AddContainerFilesStages(dockerfileContext.Resource, logger);
// When the package manager exposes production-only install args (e.g. bun's
// `--production`), emit a dedicated `prod-deps` stage that installs only the
// runtime dependencies. The runtime stage then overlays this stage's
// `node_modules` on top of the build output so the final image does not ship
// devDependencies. This mirrors the multi-stage pattern recommended at
// https://bun.com/guides/ecosystem/docker.
JavaScriptPackageManagerAnnotation? prodPackageManager = null;
JavaScriptInstallCommandAnnotation? prodInstallCommand = null;
var emitProdDepsStage =
resource.TryGetLastAnnotation(out prodPackageManager) &&
resource.TryGetLastAnnotation(out prodInstallCommand) &&
!string.IsNullOrEmpty(prodInstallCommand.ProductionInstallArgs);
if (emitProdDepsStage)
{
var pm = prodPackageManager!;
var install = prodInstallCommand!;
var prodDepsStage = dockerfileContext.Builder
.From(baseBuildImage, "prod-deps")
.EmptyLine()
.WorkDir("/app");
pm.InitializeDockerBuildStage?.Invoke(prodDepsStage);
if (pm.PackageFilesPatterns.Count > 0)
{
foreach (var packageFilePattern in pm.PackageFilesPatterns)
{
prodDepsStage.Copy(packageFilePattern.Source, packageFilePattern.Destination);
}
}
else
{
prodDepsStage.Copy("package.json", "./");
}
var prodInstallCmd = $"{pm.ExecutableName} {string.Join(' ', install.Args)} {install.ProductionInstallArgs}";
if (!string.IsNullOrEmpty(pm.CacheMount))
{
prodDepsStage.Run($"--mount=type=cache,target={pm.CacheMount} {prodInstallCmd}");
}
else
{
prodDepsStage.Run(prodInstallCmd);
}
}
var baseRuntimeImage = baseImageAnnotation?.RuntimeImage ?? DefaultBunImage;
var runtimeBuilder = dockerfileContext.Builder
.From(baseRuntimeImage, "runtime")
.EmptyLine()
.WorkDir("/app");
if (emitProdDepsStage)
{
// Mirror the multi-stage pattern recommended at https://bun.com/guides/ecosystem/docker:
// pull node_modules from the production-only install stage and the rest of the app
// source from the build context. The build stage exists for validation/caching but
// its filesystem is intentionally not copied here, because Docker's COPY --from=
// merges directories and would let devDependencies survive the overlay.
//
// A matching .dockerignore is emitted next to the published Dockerfile via the
// DockerfileBuildAnnotation.BuildContextIgnoreContent property (BuildKit's
// .dockerignore convention) so local build artifacts
// (node_modules, .git, .aspire, etc.) do not leak into the image via COPY . . below.
runtimeBuilder
.CopyFrom("prod-deps", "/app/node_modules", "./node_modules")
.Copy(".", ".");
}
else
{
runtimeBuilder.CopyFrom("build", "/app", "/app");
}
runtimeBuilder
.AddContainerFiles(dockerfileContext.Resource, "/app", logger)
.EmptyLine()
.Env("NODE_ENV", "production")
.EmptyLine()
// The official oven/bun images provide a non-root `bun` user (UID 1000).
// See https://hub.docker.com/r/oven/bun
.User("bun")
.EmptyLine()
.Entrypoint([resource.Command, scriptPath]);
});
});
// Configure pipeline to ensure container file sources are built first
resourceBuilder.WithPipelineConfiguration(context =>
{
if (resourceBuilder.Resource.TryGetAnnotationsOfType(out var containerFilesAnnotations))
{
var buildSteps = context.GetSteps(resourceBuilder.Resource, WellKnownPipelineTags.BuildCompute);
foreach (var containerFile in containerFilesAnnotations)
{
buildSteps.DependsOn(context.GetSteps(containerFile.Source, WellKnownPipelineTags.BuildCompute));
}
}
});
if (File.Exists(Path.Combine(appDirectory, "package.json")))
{
// Automatically add bun as the package manager if a package.json file exists
resourceBuilder.WithBun();
}
if (builder.ExecutionContext.IsRunMode)
{
builder.OnBeforeStart((_, _) =>
{
// Set the command to the package manager executable if a WithRunScript was configured.
// For the default Bun package manager this is a no-op (executable is "bun"), but it correctly
// handles cases where a user opts into a different package manager (e.g., WithYarn).
if (resourceBuilder.Resource.TryGetLastAnnotation(out _) &&
resourceBuilder.Resource.TryGetLastAnnotation(out var packageManager))
{
resourceBuilder.WithCommand(packageManager.ExecutableName);
}
return Task.CompletedTask;
});
}
return resourceBuilder;
}
private static IResourceBuilder WithBunDefaults(this IResourceBuilder builder) where TResource : JavaScriptAppResource =>
builder.WithOtlpExporter()
.WithRequiredCommand("bun", "https://bun.sh/docs/installation")
// Bun honors NODE_ENV for module resolution and runtime mode the same way Node does.
// See https://bun.com/docs/runtime/env
.WithEnvironment("NODE_ENV", builder.ApplicationBuilder.Environment.IsDevelopment() ? "development" : "production")
.WithCertificateTrustConfiguration((ctx) =>
{
// Configure Bun's Node-compatible custom-CA hook for append-scope trust.
// See https://bun.com/blog/bun-v1.3-nodejs-compatibility#node-extra-ca-certs.
//
// Important: Bun 1.3.10 and 1.3.14 still fail to trust Aspire's injected
// self-signed localhost certificate for outgoing HTTPS requests with
// UNABLE_TO_VERIFY_LEAF_SIGNATURE, even when NODE_EXTRA_CA_CERTS is set.
// curl --cacert and Node.js with NODE_EXTRA_CA_CERTS accept the same cert.
// Track the Bun dependency in https://github.com/microsoft/aspire/issues/17455.
if (ctx.Scope == CertificateTrustScope.Append)
{
ctx.EnvironmentVariables["NODE_EXTRA_CA_CERTS"] = ctx.CertificateBundlePath;
}
else
{
// Bun reads NODE_OPTIONS for a subset of Node flags including --use-openssl-ca,
// which switches TLS verification to the OS trust store (matching the Override
// and System scopes here). See https://bun.com/docs/cli/run#node-options.
// This does not work around the Aspire dev-certificate issue above unless that
// certificate is trusted by the selected OS/OpenSSL store.
if (ctx.EnvironmentVariables.TryGetValue("NODE_OPTIONS", out var existingOptionsObj))
{
ctx.EnvironmentVariables["NODE_OPTIONS"] = existingOptionsObj switch
{
string s when !string.IsNullOrEmpty(s) => $"{s} --use-openssl-ca",
ReferenceExpression re => ReferenceExpression.Create($"{re} --use-openssl-ca"),
_ => "--use-openssl-ca",
};
}
else
{
ctx.EnvironmentVariables["NODE_OPTIONS"] = "--use-openssl-ca";
}
}
return Task.CompletedTask;
});
///
/// Adds a JavaScript application resource to the distributed application using the specified app directory and
/// run script.
///
/// The distributed application builder to which the JavaScript application resource will be added.
/// The unique name of the JavaScript application resource. Cannot be null or empty.
/// The path to the directory containing the JavaScript application.
/// The name of the npm script to run when starting the application. Defaults to "dev". Cannot be null or empty.
/// A resource builder for the newly added JavaScript application resource.
///
/// If a Dockerfile does not exist in the application's directory, one will be generated
/// automatically when publishing. The method configures the resource with Node.js defaults and sets up npm
/// integration.
///
[AspireExport]
public static IResourceBuilder AddJavaScriptApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string runScriptName = DefaultJavaScriptRunScriptName)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(appDirectory);
ArgumentException.ThrowIfNullOrEmpty(runScriptName);
appDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, appDirectory));
var resource = new JavaScriptAppResource(name, "npm", appDirectory);
return builder.CreateDefaultJavaScriptAppBuilder(resource, appDirectory, runScriptName);
}
///
/// Configures the JavaScript application to publish as a standalone static website served by YARP.
///
/// The JavaScript resource type.
/// The JavaScript resource builder.
/// Optional callback to configure .
/// The updated resource builder.
///
///
/// The published container uses a YARP reverse proxy image for static file serving.
/// To add an API reverse-proxy, use the overload that accepts an apiPath and apiTarget.
///
///
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExportIgnore(Reason = "Use the polyglot-compatible overload instead.")]
public static IResourceBuilder PublishAsStaticWebsite(
this IResourceBuilder builder,
Action? configure = null)
where TResource : JavaScriptAppResource
{
var options = new PublishAsStaticWebsiteOptions();
configure?.Invoke(options);
return PublishAsStaticWebsiteCore(builder, null, null, options);
}
///
/// Configures the JavaScript application to publish as a standalone static website served by YARP,
/// with an API reverse-proxy to the specified resource.
///
/// The JavaScript resource type.
/// The JavaScript resource builder.
///
/// A path prefix to reverse-proxy to a backend API. For example, /api proxies all requests
/// matching /api/{"{**catch-all}"} to the backend resource.
///
///
/// The backend resource to proxy API requests to. YARP uses service discovery to resolve the
/// appropriate endpoint, preferring HTTPS when available.
///
/// Optional callback to configure .
/// The updated resource builder.
///
///
/// The published container uses a YARP reverse proxy image for static file serving and API
/// reverse-proxy. YARP natively supports HTTPS backends and service discovery, so API proxy requests
/// work correctly across all deployment targets (Docker Compose, Azure App Service, etc.).
///
///
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExportIgnore(Reason = "Use the polyglot-compatible overload instead.")]
public static IResourceBuilder PublishAsStaticWebsite(
this IResourceBuilder builder,
string apiPath,
IResourceBuilder apiTarget,
Action? configure = null)
where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(apiTarget);
var options = new PublishAsStaticWebsiteOptions();
configure?.Invoke(options);
return PublishAsStaticWebsiteCore(builder, apiPath, apiTarget, options);
}
#pragma warning disable ASPIREEXPORT009 // Polyglot entry point — collision is intentional
///
/// Publishes the JavaScript application as a standalone static website using YARP.
///
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport("publishAsStaticWebsite")]
internal static IResourceBuilder PublishAsStaticWebsitePolyglot(
#pragma warning restore ASPIREEXPORT009
this IResourceBuilder builder,
string? apiPath = null,
IResourceBuilder? apiTarget = null,
string outputPath = "dist",
bool stripPrefix = false,
string? targetEndpointName = null)
where TResource : JavaScriptAppResource
{
var options = new PublishAsStaticWebsiteOptions
{
OutputPath = outputPath,
StripPrefix = stripPrefix,
TargetEndpointName = targetEndpointName
};
return PublishAsStaticWebsiteCore(builder, apiPath, apiTarget, options);
}
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
private static IResourceBuilder PublishAsStaticWebsiteCore(
IResourceBuilder builder,
string? apiPath,
IResourceBuilder? apiTarget,
PublishAsStaticWebsiteOptions options)
where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(options.OutputPath);
if (apiPath is not null && apiTarget is null)
{
throw new ArgumentException("apiTarget is required when apiPath is specified.", nameof(apiTarget));
}
if (apiTarget is not null && apiPath is null)
{
throw new ArgumentException("apiPath is required when apiTarget is specified.", nameof(apiPath));
}
if (apiPath is not null && apiTarget is not null)
{
if (!apiPath.StartsWith('/'))
{
throw new ArgumentException("The apiPath must start with '/'.", nameof(apiPath));
}
apiPath = apiPath.TrimEnd('/');
if (apiPath.Length == 0)
{
throw new ArgumentException("The apiPath must not be '/' — it would match all requests and make the static site unreachable.", nameof(apiPath));
}
ValidateApiPath(apiPath);
builder.WithReference(apiTarget);
}
if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
return builder;
}
// YARP listens on port 5000 by default in the base image, so configure an endpoint for that port
// and set ASPNETCORE_URLS to ensure Kestrel listens on the correct port as well for static file serving and API reverse-proxy to work correctly.
builder.WithEndpoint("http", e => e.TargetPort = 5000, createIfNotExists: true);
var annotation = new JavaScriptPublishModeAnnotation(JavaScriptPublishMode.StaticWebsite)
{
OutputPath = options.OutputPath,
};
builder.WithEnvironment(ctx =>
{
ctx.EnvironmentVariables["YARP_ENABLE_STATIC_FILES"] = "true";
if (apiPath is not null && apiTarget is not null)
{
// Resolve the destination address — use a specific endpoint if configured, otherwise service discovery
var destinationAddress = options.TargetEndpointName is not null
? apiTarget.Resource.GetEndpoint(options.TargetEndpointName)
: (object)BuildServiceDiscoveryUrl(apiTarget.Resource);
ctx.EnvironmentVariables["REVERSEPROXY__ROUTES__api__CLUSTERID"] = "api";
ctx.EnvironmentVariables["REVERSEPROXY__ROUTES__api__MATCH__PATH"] = $"{apiPath}/{{**catch-all}}";
ctx.EnvironmentVariables["REVERSEPROXY__CLUSTERS__api__DESTINATIONS__destination1__ADDRESS"] = destinationAddress;
if (options.StripPrefix)
{
ctx.EnvironmentVariables["REVERSEPROXY__ROUTES__api__TRANSFORMS__0__PATHREMOVEPREFIX"] = apiPath;
}
}
});
builder.WithAnnotation(annotation)
.ClearContainerFilesSources()
.WithContainerFilesSource(GetContainerFilesSourcePath(options.OutputPath))
.WithOtlpExporter();
if (builder.Resource.TryGetLastAnnotation(out var dockerfileBuildAnnotation))
{
dockerfileBuildAnnotation.HasEntrypoint = true;
}
return builder;
}
///
/// Configures the JavaScript application to publish as a standalone Node.js server that runs a built artifact directly.
///
/// The JavaScript resource type.
/// The JavaScript resource builder.
///
/// The relative path to the Node.js entry point to execute in the published container after the build completes,
/// such as .output/server/index.mjs or build/index.js.
///
///
/// The relative path containing the built runtime files to copy into the published container. Defaults to the application root.
///
/// The updated resource builder.
///
///
/// Use this method for frameworks that produce a Node.js server artifact during the build and recommend
/// running that artifact directly in production rather than invoking a package manager script at runtime.
/// The application source is still built using the configured package manager and build script; this method
/// only changes the publish-time runtime container shape.
///
///
/// The container files source path is automatically set to so that only
/// the built output directory is copied into the runtime container, not the full application source.
///
///
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public static IResourceBuilder PublishAsNodeServer(this IResourceBuilder builder, string entryPoint, string outputPath = ".")
where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(entryPoint);
ArgumentException.ThrowIfNullOrEmpty(outputPath);
if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
return builder;
}
var annotation = new JavaScriptPublishModeAnnotation(JavaScriptPublishMode.NodeServer)
{
EntryPoint = entryPoint,
OutputPath = outputPath
};
builder.WithAnnotation(annotation)
.ClearContainerFilesSources()
.WithContainerFilesSource(GetContainerFilesSourcePath(outputPath))
.WithOtlpExporter()
.WithEnvironment("HOST", "0.0.0.0")
.WithEnvironment("HOSTNAME", "0.0.0.0");
if (builder.Resource.TryGetLastAnnotation(out var dockerfileBuildAnnotation))
{
dockerfileBuildAnnotation.HasEntrypoint = true;
}
return builder;
}
///
/// Configures the JavaScript application to publish as a Node.js server that uses a package.json script at runtime.
///
/// The JavaScript resource type.
/// The JavaScript resource builder.
///
/// The name of the package.json script to run in the published container.
/// For example, start invokes the configured package manager's run command for the start script,
/// such as npm run start, pnpm run start, yarn run start, or bun run start.
///
///
/// Optional arguments appended after the script name at runtime,
/// such as -- --port "$PORT".
///
/// The updated resource builder.
///
///
/// Use this method for frameworks where the production server depends on packages in node_modules at runtime.
/// The resulting container includes the full application with production dependencies installed.
///
///
/// This method is appropriate for frameworks like Nuxt (where useAsyncData/useFetch requires the
/// full Nitro environment), Remix (where react-router-serve is an npm dependency), and Astro SSR
/// (where the built entry point imports unbundled @astrojs/* packages).
///
///
/// For frameworks that produce a self-contained server artifact that does not require node_modules,
/// use instead for a smaller runtime image.
///
///
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public static IResourceBuilder PublishAsPackageScript(this IResourceBuilder builder, string scriptName = "start", string? runScriptArguments = null)
where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(scriptName);
if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
return builder;
}
var annotation = new JavaScriptPublishModeAnnotation(JavaScriptPublishMode.PackageScript)
{
ScriptName = scriptName,
RunScriptArguments = runScriptArguments
};
builder.WithAnnotation(annotation)
.ClearContainerFilesSources()
.WithOtlpExporter()
.WithEnvironment("HOST", "0.0.0.0")
.WithEnvironment("HOSTNAME", "0.0.0.0");
if (builder.Resource.TryGetLastAnnotation(out var dockerfileBuildAnnotation))
{
dockerfileBuildAnnotation.HasEntrypoint = true;
}
return builder;
}
private static void AddInstallCommand(this DockerfileStage builderStage, JavaScriptPackageManagerAnnotation packageManager, JavaScriptInstallCommandAnnotation installCommand)
{
// Use BuildKit cache mount for package manager cache if available
var installCmd = $"{packageManager.ExecutableName} {string.Join(' ', installCommand.Args)}";
if (!string.IsNullOrEmpty(packageManager.CacheMount))
{
builderStage.Run($"--mount=type=cache,target={packageManager.CacheMount} {installCmd}");
}
else
{
builderStage.Run(installCmd);
}
}
private static string GetPackageScriptRuntimeImage(
string appDirectory,
IServiceProvider services,
DockerfileBaseImageAnnotation? baseImageAnnotation,
JavaScriptPackageManagerAnnotation packageManager,
string buildImage)
{
if (!string.IsNullOrEmpty(baseImageAnnotation?.RuntimeImage))
{
return baseImageAnnotation.RuntimeImage;
}
return packageManager.ResolvePackageScriptRuntimeImage?.Invoke(buildImage)
?? GetDefaultBaseImage(appDirectory, "alpine", services);
}
private static IResourceBuilder CreateDefaultJavaScriptAppBuilder(
this IDistributedApplicationBuilder builder,
TResource resource,
string appDirectory,
string runScriptName,
Action? argsCallback = null) where TResource : JavaScriptAppResource
{
var resourceBuilder = builder.AddResource(resource)
.WithNodeDefaults()
.WithArgs(c =>
{
if (c.Resource.TryGetLastAnnotation(out var runCommand))
{
if (c.Resource.TryGetLastAnnotation(out var packageManager) &&
!string.IsNullOrEmpty(packageManager.ScriptCommand))
{
c.Args.Add(packageManager.ScriptCommand);
}
c.Args.Add(runCommand.ScriptName);
foreach (var arg in runCommand.Args)
{
c.Args.Add(arg);
}
}
argsCallback?.Invoke(c);
})
.WithIconName("CodeJsRectangle")
.WithNpm()
.PublishAsDockerFile(c =>
{
// Only generate a Dockerfile if one doesn't already exist in the app directory
if (File.Exists(Path.Combine(appDirectory, "Dockerfile")))
{
return;
}
c.WithDockerfileBuilder(appDirectory, dockerfileContext =>
{
dockerfileContext.Resource.TryGetLastAnnotation(out var publishMode);
if (c.Resource.TryGetLastAnnotation(out var packageManager))
{
// Get custom base image from annotation, if present
dockerfileContext.Resource.TryGetLastAnnotation(out var baseImageAnnotation);
var baseImage = baseImageAnnotation?.BuildImage ?? GetDefaultBaseImage(appDirectory, "slim", dockerfileContext.Services);
var dockerBuilder = publishMode is not null
? dockerfileContext.Builder.From(baseImage, "build").WorkDir("/app")
: dockerfileContext.Builder.From(baseImage).WorkDir("/app");
// Initialize the Docker build stage with package manager-specific setup commands
// for the default JavaScript app builder (used by Vite and other build-less apps).
packageManager.InitializeDockerBuildStage?.Invoke(dockerBuilder);
var copiedAllSource = false;
// Copy package files first for better layer caching
if (packageManager.PackageFilesPatterns.Count > 0)
{
foreach (var packageFilePattern in packageManager.PackageFilesPatterns)
{
dockerBuilder.Copy(packageFilePattern.Source, packageFilePattern.Destination);
}
}
else
{
dockerBuilder.Copy(".", ".");
copiedAllSource = true;
}
if (c.Resource.TryGetLastAnnotation(out var installCommand))
{
dockerBuilder.AddInstallCommand(packageManager, installCommand);
}
if (!copiedAllSource)
{
// Copy application source code after dependencies are installed
dockerBuilder.Copy(".", ".");
}
if (c.Resource.TryGetLastAnnotation(out var buildCommand))
{
var commandArgs = new List() { packageManager.ExecutableName };
if (!string.IsNullOrEmpty(packageManager.ScriptCommand))
{
commandArgs.Add(packageManager.ScriptCommand);
}
commandArgs.Add(buildCommand.ScriptName);
commandArgs.AddRange(buildCommand.Args);
dockerBuilder.Run(string.Join(' ', commandArgs));
}
switch (publishMode?.Mode)
{
case JavaScriptPublishMode.StaticWebsite:
{
var runtimeImage = baseImageAnnotation?.RuntimeImage ?? DefaultYarpImage;
var distPath = GetContainerFilesSourcePath(publishMode.OutputPath);
dockerfileContext.Builder
.From(runtimeImage, "runtime")
.WorkDir("/app")
.CopyFrom("build", distPath, "/app/wwwroot")
.Entrypoint(["dotnet", "/app/yarp.dll"]);
break;
}
case JavaScriptPublishMode.NodeServer:
{
var runtimeImage = baseImageAnnotation?.RuntimeImage ?? GetDefaultBaseImage(appDirectory, "alpine", dockerfileContext.Services);
var outputPath = GetContainerFilesSourcePath(publishMode.OutputPath);
dockerfileContext.Builder
.From(runtimeImage, "runtime")
.WorkDir("/app")
.CopyFrom("build", outputPath, outputPath)
.Env("NODE_ENV", "production")
.User("node")
.Entrypoint(["node", NormalizeRelativePath(publishMode.EntryPoint!)]);
break;
}
case JavaScriptPublishMode.PackageScript:
{
var runtimeImage = GetPackageScriptRuntimeImage(appDirectory, dockerfileContext.Services, baseImageAnnotation, packageManager, baseImage);
// Production dependencies stage for optimized image
var prodDepsStage = dockerfileContext.Builder
.From(baseImage, "prod-deps")
.WorkDir("/app");
packageManager.InitializeDockerBuildStage?.Invoke(prodDepsStage);
if (packageManager.PackageFilesPatterns.Count > 0)
{
foreach (var packageFilePattern in packageManager.PackageFilesPatterns)
{
prodDepsStage.Copy(packageFilePattern.Source, packageFilePattern.Destination);
}
}
else
{
prodDepsStage.Copy("package*.json", "./");
}
// Install production-only dependencies using the same base install
// command as the build stage (e.g. 'ci' for npm, 'install --frozen-lockfile'
// for pnpm) plus the production-only flag (e.g. '--omit=dev').
var installAnnotation = c.Resource.TryGetLastAnnotation(out var installCmd) ? installCmd : null;
if (string.IsNullOrEmpty(installAnnotation?.ProductionInstallArgs))
{
throw new InvalidOperationException($"Package manager '{packageManager.ExecutableName}' does not have ProductionInstallArgs configured, which is required for PublishAsPackageScript.");
}
var prodInstallCmd = $"{packageManager.ExecutableName} {string.Join(' ', installAnnotation.Args)} {installAnnotation.ProductionInstallArgs}";
if (!string.IsNullOrEmpty(packageManager.CacheMount))
{
prodDepsStage.Run($"--mount=type=cache,target={packageManager.CacheMount} {prodInstallCmd}");
}
else
{
prodDepsStage.Run(prodInstallCmd);
}
// Runtime stage: copy build output then overlay prod deps
var runCommand = string.IsNullOrWhiteSpace(publishMode.RunScriptArguments)
? $"{packageManager.ExecutableName} {packageManager.ScriptCommand ?? "run"} {publishMode.ScriptName}"
: $"{packageManager.ExecutableName} {packageManager.ScriptCommand ?? "run"} {publishMode.ScriptName} {publishMode.RunScriptArguments}";
var runtimeStage = dockerfileContext.Builder
.From(runtimeImage, "runtime")
.WorkDir("/app")
.CopyFrom("build", "/app", "/app")
.CopyFrom("prod-deps", "/app/node_modules", "./node_modules");
packageManager.InitializeDockerRuntimeStage?.Invoke(runtimeStage);
runtimeStage
.Env("NODE_ENV", "production")
.Entrypoint(["sh", "-c", $"exec {runCommand}"]);
break;
}
case JavaScriptPublishMode.NextStandalone:
{
var runtimeImage = baseImageAnnotation?.RuntimeImage ?? GetDefaultBaseImage(appDirectory, "alpine", dockerfileContext.Services);
// Match the ownership pattern from the official Next.js sample:
// https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
dockerfileContext.Builder
.From(runtimeImage, "runtime")
.WorkDir("/app")
.Env("NODE_ENV", "production")
.CopyFrom("build", "/app/public", "./public", "node:node")
.Run("mkdir .next")
.Run("chown node:node .next")
.CopyFrom("build", "/app/.next/standalone", "./", "node:node")
.CopyFrom("build", "/app/.next/static", "./.next/static", "node:node")
.User("node")
.Entrypoint(["node", "server.js"]);
break;
}
}
}
});
// JavaScript apps default to build-only publishing unless a standalone runtime is enabled.
if (resource.TryGetLastAnnotation(out var dockerFileAnnotation))
{
dockerFileAnnotation.HasEntrypoint =
resource.TryGetLastAnnotation(out _);
}
else
{
throw new InvalidOperationException("DockerfileBuildAnnotation should exist after calling PublishAsDockerFile.");
}
})
.WithAnnotation(new ContainerFilesSourceAnnotation() { SourcePath = "/app/dist" })
.WithBuildScript("build")
.WithRunScript(runScriptName);
if (builder.ExecutionContext.IsPublishMode &&
builder.TryCreateResourceBuilder(resource.Name, out var containerBuilder))
{
var validationStepName = $"validate-javascript-dockerfile-run-script-{resource.Name}";
Task WriteValidatedContainerAsync(ManifestPublishingContext context)
{
ValidateExistingDockerfileRunScript(resource, containerBuilder.Resource);
return context.WriteContainerAsync(containerBuilder.Resource);
}
resourceBuilder.WithManifestPublishingCallback(WriteValidatedContainerAsync);
containerBuilder.WithManifestPublishingCallback(WriteValidatedContainerAsync);
containerBuilder.WithAnnotation(new PipelineStepAnnotation(_ => new PipelineStep
{
Name = validationStepName,
Description = $"Validates that JavaScript app '{resource.Name}' does not publish an ignored run script with an existing Dockerfile.",
RequiredBySteps = [WellKnownPipelineSteps.Build, WellKnownPipelineSteps.Publish],
Resource = containerBuilder.Resource,
Action = _ =>
{
ValidateExistingDockerfileRunScript(resource, containerBuilder.Resource);
return Task.CompletedTask;
}
}));
}
resourceBuilder.WithVSCodeDebugging();
// ensure the package manager command is set before starting the resource
if (builder.ExecutionContext.IsRunMode)
{
builder.OnBeforeStart((_, _) =>
{
if (resourceBuilder.Resource.TryGetLastAnnotation(out var packageManager))
{
resourceBuilder.WithCommand(packageManager.ExecutableName);
}
return Task.CompletedTask;
});
}
return resourceBuilder;
}
private static void ValidateExistingDockerfileRunScript(JavaScriptAppResource resource, ContainerResource containerResource)
{
if (containerResource.Entrypoint is not null ||
!containerResource.TryGetLastAnnotation(out var dockerfileBuildAnnotation) ||
dockerfileBuildAnnotation.DockerfileFactory is not null ||
!containerResource.TryGetLastAnnotation(out var runScript))
{
return;
}
// The user's effective run-script intent is captured by the last annotation: AddJavaScriptApp
// always adds one with the supplied runScriptName, and any subsequent WithRunScript call
// appends another. Comparing the last annotation against the default avoids false positives
// when the user re-states the default explicitly (e.g. .WithRunScript("dev")).
var hasExplicitRunScript =
!string.Equals(runScript.ScriptName, DefaultJavaScriptRunScriptName, StringComparison.Ordinal) ||
runScript.Args is { Length: > 0 };
if (!hasExplicitRunScript)
{
return;
}
// Include the args in the message when they are the trigger, so the user can see why
// a default-named script (e.g. "dev") still produced a conflict.
var argsClause = runScript.Args is { Length: > 0 }
? $" with args [{string.Join(", ", runScript.Args)}]"
: string.Empty;
// Existing Dockerfiles are user-authored, so Aspire cannot safely assume that replacing
// their entrypoint with a package-manager script will work for the image shape.
// If the user provides an explicit container entrypoint above, honor it; otherwise fail
// instead of silently publishing an image that ignores the requested run script.
throw new DistributedApplicationException(
$"JavaScript app resource '{resource.Name}' is configured to run script '{runScript.ScriptName}'{argsClause}, but publish is using the existing Dockerfile '{dockerfileBuildAnnotation.DockerfilePath}'. " +
"An existing Dockerfile entrypoint cannot be changed automatically from runScriptName or WithRunScript. " +
"Remove or rename the Dockerfile so Aspire can generate one, or call PublishAsDockerFile(...) and set the container entrypoint explicitly.");
}
///
/// Adds a Vite app to the distributed application builder.
///
/// The to add the resource to.
/// The name of the Vite app.
/// The path to the directory containing the Vite app.
/// The name of the script that runs the Vite app. Defaults to "dev".
/// A reference to the .
/// The resource builder.
///
///
/// The following example creates a Vite app using npm as the package manager.
///
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddViteApp("frontend", "./frontend");
///
/// builder.Build().Run();
///
///
///
[AspireExport]
public static IResourceBuilder AddViteApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string runScriptName = "dev")
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(appDirectory);
appDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, appDirectory));
var resource = new ViteAppResource(name, "npm", appDirectory);
var resourceBuilder = builder.CreateDefaultJavaScriptAppBuilder(
resource,
appDirectory,
runScriptName,
argsCallback: c =>
{
// pnpm does not strip the -- separator and passes it to the script, causing Vite to ignore subsequent arguments.
// npm and yarn both strip the -- separator before passing arguments to the script.
// Only add the separator for when necessary.
if (c.Resource.TryGetLastAnnotation(out var packageManager) &&
packageManager.CommandSeparator is string separator)
{
c.Args.Add(separator);
}
var targetEndpoint = resource.GetEndpoint("https");
if (!targetEndpoint.Exists)
{
targetEndpoint = resource.GetEndpoint("http");
}
c.Args.Add("--port");
c.Args.Add(targetEndpoint.Property(EndpointProperty.TargetPort));
if (!string.IsNullOrEmpty(resource.ViteConfigPath))
{
c.Args.Add("--config");
c.Args.Add(resource.ViteConfigPath);
}
})
.WithHttpEndpoint(env: "PORT")
// Making TLS opt-in for Vite for now
.WithoutHttpsCertificate()
.WithHttpsCertificateConfiguration(async ctx =>
{
string? configTarget = resource.ViteConfigPath;
// First we need to determine if there's an existing --config argument specified
var cfgIndex = ctx.Arguments.IndexOf("--config");
if (cfgIndex >= 0 && cfgIndex + 1 < ctx.Arguments.Count)
{
configTarget = ctx.Arguments[cfgIndex + 1] switch
{
string s when !string.IsNullOrEmpty(s) && !s.StartsWith("--") => s,
ReferenceExpression re => await re.GetValueAsync(ctx.CancellationToken).ConfigureAwait(false),
_ => null,
};
if (string.IsNullOrEmpty(configTarget))
{
// Couldn't determine the config target, so don't modify anything
return;
}
// Remove the original --config argument and its value
ctx.Arguments.RemoveAt(cfgIndex);
ctx.Arguments.RemoveAt(cfgIndex);
}
else if (cfgIndex >= 0)
{
// --config argument is present but is missing a value
return;
}
if (string.IsNullOrEmpty(configTarget))
{
// The user didn't specify a specific vite config file, so we need to look for one of the default config files
foreach (var configFile in s_defaultConfigFiles)
{
var candidatePath = Path.GetFullPath(Path.Join(appDirectory, configFile));
if (File.Exists(candidatePath))
{
configTarget = candidatePath;
break;
}
}
}
if (configTarget is not null)
{
try
{
// Determine the absolute path to the original config file
var absoluteConfigPath = Path.GetFullPath(configTarget, appDirectory);
// Determine the relative path from the Aspire vite config to the original config file
var relativeConfigPath = Path.GetRelativePath(Path.Join(appDirectory, "node_modules", ".bin"), absoluteConfigPath);
// If we are expecting to run the vite app with HTTPS termination, generate an Aspire specific Vite config file that can mutate the user's original config
var aspireConfig = AspireViteConfig
.Replace(AspireViteRelativeConfigToken, relativeConfigPath.Replace("\\", "/"), StringComparison.Ordinal)
.Replace(AspireViteAbsoluteConfigToken, absoluteConfigPath.Replace("\\", "\\\\"), StringComparison.Ordinal);
var aspireConfigPath = Path.Join(appDirectory, "node_modules", ".bin", $"aspire.{Path.GetFileName(configTarget)}");
File.WriteAllText(aspireConfigPath, aspireConfig);
// Override the path to the Vite config file to use the Aspire generated one. If we made it here, we
// know there isn't an existing --config argument present.
ctx.Arguments.Add("--config");
ctx.Arguments.Add(aspireConfigPath);
ctx.EnvironmentVariables["TLS_CONFIG_PFX"] = ctx.PfxPath;
if (ctx.Password is not null)
{
ctx.EnvironmentVariables["TLS_CONFIG_PASSWORD"] = ctx.Password;
}
}
catch (Exception ex)
{
var resourceLoggerService = ctx.ExecutionContext.ServiceProvider.GetRequiredService();
var resourceLogger = resourceLoggerService.GetLogger(resource);
resourceLogger.LogWarning(ex, "Failed to generate Aspire Vite HTTPS config wrapper for resource '{ResourceName}'. Falling back to existing Vite config without Aspire modifications. Automatic HTTPS configuration won't be available", resource.Name);
if (!string.IsNullOrEmpty(configTarget))
{
// Fallback to using the existing config target
ctx.Arguments.Add("--config");
ctx.Arguments.Add(configTarget);
}
}
}
});
if (builder.ExecutionContext.IsRunMode)
{
// Vite only supports a single endpoint, so we have to modify the existing endpoint to use HTTPS instead of
// adding a new one.
resourceBuilder.SubscribeHttpsEndpointsUpdate(ctx =>
{
resourceBuilder.WithEndpoint("http", ep => ep.UriScheme = "https");
});
}
return resourceBuilder;
}
///
/// Adds a Next.js app to the distributed application builder.
///
/// The to add the resource to.
/// The name of the Next.js app.
/// The path to the directory containing the Next.js app.
/// The name of the script that runs the Next.js dev server. Defaults to "dev".
/// A reference to the .
/// The resource builder.
///
///
/// This method configures the Next.js application for both local development and publishing.
/// In run mode, it starts the Next.js dev server with the correct port binding.
/// In publish mode, it generates a multi-stage Dockerfile using Next.js standalone output mode,
/// which copies public/, .next/standalone/, and .next/static/ into a
/// Node.js runtime container.
///
///
/// The Next.js application must have output: "standalone" configured in next.config.ts
/// and a public/ directory (even if empty) for the published container to build correctly.
///
///
/// The following example creates a Next.js app.
///
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddNextJsApp("frontend", "./frontend");
///
/// builder.Build().Run();
///
///
///
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public static IResourceBuilder AddNextJsApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string runScriptName = "dev")
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(appDirectory);
appDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, appDirectory));
var resource = new NextJsAppResource(name, "npm", appDirectory);
var resourceBuilder = builder.CreateDefaultJavaScriptAppBuilder(
resource,
appDirectory,
runScriptName,
argsCallback: c =>
{
if (c.Resource.TryGetLastAnnotation(out var packageManager) &&
packageManager.CommandSeparator is string separator)
{
c.Args.Add(separator);
}
var targetEndpoint = resource.GetEndpoint("https");
if (!targetEndpoint.Exists)
{
targetEndpoint = resource.GetEndpoint("http");
}
c.Args.Add("-p");
c.Args.Add(targetEndpoint.Property(EndpointProperty.TargetPort));
})
.WithHttpEndpoint(env: "PORT")
.WithOtlpExporter();
if (builder.ExecutionContext.IsPublishMode)
{
resourceBuilder
.WithAnnotation(new JavaScriptPublishModeAnnotation(JavaScriptPublishMode.NextStandalone))
.ClearContainerFilesSources()
.WithEnvironment("HOSTNAME", "0.0.0.0");
if (resourceBuilder.Resource.TryGetLastAnnotation(out var dockerfileBuildAnnotation))
{
dockerfileBuildAnnotation.HasEntrypoint = true;
}
}
// Add a publish prereq step that validates the Next.js config has standalone output enabled.
// This runs at deploy time (not resource creation time) so it doesn't block `aspire start`.
// Can be disabled with .DisableBuildValidation().
resourceBuilder.WithAnnotation(new PipelineStepAnnotation(factoryCtx =>
[
new PipelineStep
{
Name = $"nextjs-standalone-check-{name}",
Description = $"Validates that the Next.js app '{name}' has output: \"standalone\" configured.",
DependsOnSteps = [WellKnownPipelineSteps.BuildPrereq],
RequiredBySteps = [WellKnownPipelineSteps.Build],
Resource = resourceBuilder.Resource,
Action = _ =>
{
if (!resourceBuilder.Resource.TryGetLastAnnotation(out var suppress))
{
ValidateNextJsStandaloneOutput(appDirectory);
}
return Task.CompletedTask;
}
}
]));
return resourceBuilder;
}
///
/// Disables deploy-time build validation checks for the Next.js application.
///
/// The resource builder.
/// The resource builder for chaining.
///
/// By default, adds publish prerequisite steps that verify
/// the Next.js configuration (e.g. that output: "standalone" is set). Use this method
/// to suppress those checks when the configuration is set dynamically or via an external
/// mechanism that cannot be detected by static file inspection.
///
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public static IResourceBuilder DisableBuildValidation(this IResourceBuilder builder)
{
return builder.WithAnnotation(new());
}
///
/// Configures the Vite app to use the specified Vite configuration file instead of the default resolution behavior.
///
/// The resource builder.
/// The path to the Vite configuration file. Relative to the Vite service project root.
/// The resource builder.
///
/// Use this method to specify a specific Vite configuration file if you need to override the default Vite configuration resolution behavior.
///
///
/// Use a custom Vite configuration file:
///
/// var builder = DistributedApplication.CreateBuilder(args);
/// var viteApp = builder.AddViteApp("frontend", "./frontend")
/// .WithViteConfig("./vite.production.config.js");
///
///
[AspireExport]
public static IResourceBuilder WithViteConfig(this IResourceBuilder builder, string configPath)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(configPath);
builder.Resource.ViteConfigPath = configPath;
return builder;
}
///
/// Configures the Node.js resource to use npm as the package manager and optionally installs packages before the application starts.
///
/// The NodeAppResource.
/// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource.
/// The install command itself passed to npm to install dependencies.
/// The command-line arguments passed to npm to install dependencies.
/// A reference to the .
/// The resource builder.
[AspireExport]
public static IResourceBuilder WithNpm(this IResourceBuilder resource, bool install = true, string? installCommand = null, string[]? installArgs = null) where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(resource);
installCommand ??= GetDefaultNpmInstallCommand(resource);
resource
.WithAnnotation(new JavaScriptPackageManagerAnnotation("npm", runScriptCommand: "run", cacheMount: "/root/.npm")
{
PackageFilesPatterns = { new CopyFilePattern("package*.json", "./") },
})
.WithAnnotation(new JavaScriptInstallCommandAnnotation([installCommand, .. installArgs ?? []])
{
ProductionInstallArgs = "--omit=dev"
})
.WithRequiredCommand("npm", "https://docs.npmjs.com/downloading-and-installing-node-js-and-npm");
AddInstaller(resource, install);
return resource;
}
///
/// Configures the JavaScript resource to use Bun as the package manager and optionally installs packages before the application starts.
///
/// The JavaScript application resource builder.
/// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource.
/// Additional command-line arguments passed to "bun install". When null, defaults are applied based on publish mode and lockfile presence.
/// A reference to the .
/// The resource builder.
///
/// Bun forwards script arguments without requiring the -- command separator, so this method configures the resource to omit it.
/// When publishing and a bun lockfile (bun.lock or bun.lockb) is present, --frozen-lockfile is used by default.
/// Publishing to a container requires Bun to be present in the build image. This method configures a Bun build image when one is not already specified.
/// also uses the Bun image for the runtime stage unless a custom runtime image is configured.
/// To use a specific Bun version, configure a custom build image (for example, oven/bun:<tag>) using .
///
///
///
/// Run a Vite app using Bun as the package manager:
///
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddViteApp("frontend", "./frontend")
/// .WithBun()
/// .WithDockerfileBaseImage(buildImage: "oven/bun:latest"); // To use a specific Bun image
///
/// builder.Build().Run();
///
///
[AspireExport]
public static IResourceBuilder WithBun(this IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(resource);
var workingDirectory = resource.Resource.WorkingDirectory;
var hasBunLock = File.Exists(Path.Combine(workingDirectory, "bun.lock")) ||
File.Exists(Path.Combine(workingDirectory, "bun.lockb"));
installArgs ??= GetDefaultBunInstallArgs(resource, hasBunLock);
var packageFilesSourcePattern = "package.json";
if (File.Exists(Path.Combine(workingDirectory, "bun.lock")))
{
packageFilesSourcePattern += " bun.lock";
}
if (File.Exists(Path.Combine(workingDirectory, "bun.lockb")))
{
packageFilesSourcePattern += " bun.lockb";
}
resource
.WithAnnotation(new JavaScriptPackageManagerAnnotation("bun", runScriptCommand: "run", cacheMount: "/root/.bun/install/cache")
{
PackageFilesPatterns = { new CopyFilePattern(packageFilesSourcePattern, "./") },
// bun supports passing script flags without the `--` separator.
CommandSeparator = null,
ResolvePackageScriptRuntimeImage = buildImage => buildImage,
})
.WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])
{
ProductionInstallArgs = "--production"
})
.WithRequiredCommand("bun", "https://bun.sh/docs/installation");
if (!resource.Resource.TryGetLastAnnotation(out _))
{
// bun is not available in the default Node.js base images used for publish-mode Dockerfile generation.
// We override the build image so that the install and build steps can execute with bun.
resource.WithAnnotation(new DockerfileBaseImageAnnotation
{
// Use a constant major version tag to keep builds deterministic.
BuildImage = "oven/bun:1",
});
}
AddInstaller(resource, install);
return resource;
}
private static string[] GetDefaultBunInstallArgs(IResourceBuilder resource, bool hasBunLock) =>
resource.ApplicationBuilder.ExecutionContext.IsPublishMode && hasBunLock
? ["--frozen-lockfile"]
: [];
private static string GetDefaultNpmInstallCommand(IResourceBuilder resource) =>
resource.ApplicationBuilder.ExecutionContext.IsPublishMode &&
File.Exists(Path.Combine(resource.Resource.WorkingDirectory, "package-lock.json"))
? "ci"
: "install";
///
/// Configures the Node.js resource to use yarn as the package manager and optionally installs packages before the application starts.
///
/// The NodeAppResource.
/// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource.
/// The command-line arguments passed to "yarn install".
/// A reference to the .
/// The resource builder.
[AspireExport]
public static IResourceBuilder WithYarn(this IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(resource);
var workingDirectory = resource.Resource.WorkingDirectory;
var hasYarnLock = File.Exists(Path.Combine(workingDirectory, "yarn.lock"));
var hasYarnrc = File.Exists(Path.Combine(workingDirectory, ".yarnrc.yml"));
var hasYarnBerryDir = Directory.Exists(Path.Combine(workingDirectory, ".yarn"));
var hasYarnBerry = hasYarnrc || hasYarnBerryDir;
installArgs ??= GetDefaultYarnInstallArgs(resource, hasYarnLock, hasYarnBerry);
var cacheMount = hasYarnBerry ? ".yarn/cache" : "/root/.cache/yarn";
var packageManager = new JavaScriptPackageManagerAnnotation("yarn", runScriptCommand: "run", cacheMount)
{
// Yarn doesn't require "--" separator
// Yarn v1 strips the separator automatically but produces the warning suggesting to remove it.
// Later Yarn versions don't strip the separator and pass it to the script as-is, causing Vite to ignore subsequent arguments.
CommandSeparator = null,
};
var packageFilesSourcePattern = "package.json";
if (hasYarnLock)
{
packageFilesSourcePattern += " yarn.lock";
}
if (hasYarnrc)
{
packageFilesSourcePattern += " .yarnrc.yml";
}
packageManager.PackageFilesPatterns.Add(new CopyFilePattern(packageFilesSourcePattern, "./"));
if (hasYarnBerryDir)
{
packageManager.PackageFilesPatterns.Add(new CopyFilePattern(".yarn", "./.yarn"));
}
resource
.WithAnnotation(packageManager)
.WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])
{
ProductionInstallArgs = "--production"
})
.WithRequiredCommand("yarn", "https://yarnpkg.com/getting-started/install");
AddInstaller(resource, install);
return resource;
}
private static string[] GetDefaultYarnInstallArgs(
IResourceBuilder resource,
bool hasYarnLock,
bool hasYarnBerry)
{
if (!resource.ApplicationBuilder.ExecutionContext.IsPublishMode ||
!hasYarnLock)
{
// Not publish mode or no yarn.lock, use default install args
return [];
}
if (hasYarnBerry)
{
// Yarn 2+ detected, --frozen-lockfile is deprecated in v2+, use --immutable instead
return ["--immutable"];
}
// Fallback: default to Yarn v1.x behavior
return ["--frozen-lockfile"];
}
///
/// Configures the Node.js resource to use pnpm as the package manager and optionally installs packages before the application starts.
///
/// The NodeAppResource.
/// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource.
/// The command-line arguments passed to "pnpm install".
/// A reference to the .
/// The resource builder.
[AspireExport]
public static IResourceBuilder WithPnpm(this IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(resource);
var workingDirectory = resource.Resource.WorkingDirectory;
var hasPnpmLock = File.Exists(Path.Combine(workingDirectory, "pnpm-lock.yaml"));
var hasPnpmWorkspace = File.Exists(Path.Combine(workingDirectory, "pnpm-workspace.yaml"));
installArgs ??= GetDefaultPnpmInstallArgs(resource, hasPnpmLock);
var packageFilesSourcePattern = "package.json";
if (hasPnpmLock)
{
packageFilesSourcePattern += " pnpm-lock.yaml";
}
if (hasPnpmWorkspace)
{
packageFilesSourcePattern += " pnpm-workspace.yaml";
}
resource
.WithAnnotation(new JavaScriptPackageManagerAnnotation("pnpm", runScriptCommand: "run", cacheMount: "/pnpm/store")
{
PackageFilesPatterns = { new CopyFilePattern(packageFilesSourcePattern, "./") },
// pnpm does not strip the -- separator and passes it to the script, causing Vite to ignore subsequent arguments.
CommandSeparator = null,
// pnpm is not included in the Node.js Docker image by default, so we need to enable it via corepack
InitializeDockerBuildStage = stage => stage.Run("corepack enable pnpm"),
InitializeDockerRuntimeStage = stage =>
{
// Corepack's shim is not enough by itself: without invoking pnpm during the image build,
// the first container start can try to download pnpm before running the app.
stage.Run("corepack enable pnpm && pnpm --version");
},
})
.WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])
{
ProductionInstallArgs = "--prod"
})
.WithRequiredCommand("pnpm", "https://pnpm.io/installation");
AddInstaller(resource, install);
return resource;
}
private static string[] GetDefaultPnpmInstallArgs(IResourceBuilder resource, bool hasPnpmLock) =>
resource.ApplicationBuilder.ExecutionContext.IsPublishMode && hasPnpmLock
? ["--frozen-lockfile"]
: [];
///
/// Adds a build script annotation to the resource builder using the specified command-line arguments.
///
/// The type of JavaScript application resource being configured.
/// The resource builder to which the build script annotation will be added.
/// The name of the script to be executed when the resource is built.
/// An array of command-line arguments to use for the build script.
/// The same resource builder instance with the build script annotation applied.
///
/// Use this method to specify custom build scripts for JavaScript application resources during
/// deployment.
///
[AspireExport]
public static IResourceBuilder WithBuildScript(this IResourceBuilder resource, string scriptName, string[]? args = null) where TResource : JavaScriptAppResource
{
return resource.WithAnnotation(new JavaScriptBuildScriptAnnotation(scriptName, args));
}
///
/// Adds a run script annotation to the specified JavaScript application resource builder, specifying the script to
/// execute and its arguments during run mode.
///
/// The type of the JavaScript application resource being configured. Must inherit from JavaScriptAppResource.
/// The resource builder to which the run script annotation will be added.
/// The name of the script to be executed when the resource is run.
/// An array of arguments to pass to the script.
/// The same resource builder instance with the run script annotation applied, enabling further configuration.
///
/// Use this method to specify a custom script and its arguments that should be executed when the resource is executed
/// in RunMode.
///
[AspireExport]
public static IResourceBuilder WithRunScript(this IResourceBuilder resource, string scriptName, string[]? args = null) where TResource : JavaScriptAppResource
{
return resource.WithAnnotation(new JavaScriptRunScriptAnnotation(scriptName, args));
}
[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder builder, string scriptPath)
where T : NodeAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(scriptPath);
var resource = builder.Resource;
var workingDirectory = Path.GetFullPath(resource.WorkingDirectory);
return builder.WithDebugSupport(
mode =>
{
// Compute at run time so the launch config reflects the final annotation state
var hasRunScript = resource.TryGetLastAnnotation(out _);
var hasPackageManager = resource.TryGetLastAnnotation(out var pmAnnotation);
var runtimeExecutable = hasRunScript && hasPackageManager ? pmAnnotation!.ExecutableName : "node";
return new NodeLaunchConfiguration
{
ScriptPath = Path.GetFullPath(scriptPath, workingDirectory),
Mode = mode,
RuntimeExecutable = runtimeExecutable,
WorkingDirectory = workingDirectory
};
},
"node");
}
[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder builder)
where T : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(builder);
var resource = builder.Resource;
var workingDirectory = Path.GetFullPath(resource.WorkingDirectory);
return builder.WithDebugSupport(
mode =>
{
// Compute at run time so the launch config reflects the final annotation state
var packageManager = "npm";
if (resource.TryGetLastAnnotation(out var pmAnnotation))
{
packageManager = pmAnnotation.ExecutableName;
}
return new NodeLaunchConfiguration
{
ScriptPath = string.Empty,
Mode = mode,
RuntimeExecutable = packageManager,
WorkingDirectory = workingDirectory
};
},
"node");
}
///
/// Configures a browser debugger for the JavaScript application resource, enabling browser-based debugging
/// through a child resource that launches when the parent application is ready.
///
/// The type of the JavaScript application resource.
/// The resource builder for the JavaScript application.
/// The browser to use for debugging. Defaults to "msedge". Supported values include "msedge" and "chrome".
/// A reference to the for chaining additional configuration.
/// The resource builder.
///
/// This method creates a child that waits for the parent JavaScript
/// application to start, then launches a browser debug session targeting the parent's HTTP or HTTPS endpoint.
/// The parent resource must have at least one HTTP or HTTPS endpoint configured.
///
///
/// Thrown when the parent resource does not have an HTTP or HTTPS endpoint, or when the IDE extension
/// does not support browser debugging.
///
///
/// Add browser debugging to a JavaScript application:
///
/// var builder = DistributedApplication.CreateBuilder(args);
/// builder.AddViteApp("frontend", "./frontend")
/// .WithBrowserDebugger();
///
///
[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public static IResourceBuilder WithBrowserDebugger(
this IResourceBuilder builder,
string browser = "msedge")
where T : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(builder);
// Validate that the extension supports browser debugging if we're running in an extension context
ValidateBrowserCapability(builder);
var parentResource = builder.Resource;
var debuggerResourceName = $"{parentResource.Name}-browser";
var debuggerResource = new BrowserDebuggerResource(debuggerResourceName, browser, parentResource.WorkingDirectory);
builder.ApplicationBuilder.AddResource(debuggerResource)
.WithParentRelationship(parentResource)
.WaitFor(builder)
.ExcludeFromManifest()
.WithDebugSupport(
mode =>
{
// Resolve endpoint at run time so dynamically added endpoints are reflected
EndpointAnnotation? endpointAnnotation = null;
if (parentResource.TryGetAnnotationsOfType(out var endpoints))
{
endpointAnnotation = endpoints.FirstOrDefault(e => e.UriScheme == "https")
?? endpoints.FirstOrDefault(e => e.UriScheme == "http");
}
if (endpointAnnotation is null)
{
throw new InvalidOperationException(
$"Resource '{parentResource.Name}' does not have an HTTP or HTTPS endpoint. Browser debugging requires an endpoint to navigate to.");
}
var endpointReference = parentResource.GetEndpoint(endpointAnnotation.Name);
return new BrowserLaunchConfiguration
{
Mode = mode,
Url = endpointReference.Url,
WebRoot = parentResource.WorkingDirectory,
Browser = browser
};
},
BrowserCapability);
return builder;
}
private static void ValidateBrowserCapability(IResourceBuilder builder) where T : IResource
{
var configuration = builder.ApplicationBuilder.Configuration;
try
{
if (configuration["DEBUG_SESSION_INFO"] is { } debugSessionInfoJson
&& JsonSerializer.Deserialize(debugSessionInfoJson) is { } info
&& info.SupportedLaunchConfigurations is not null
&& !info.SupportedLaunchConfigurations.Contains(BrowserCapability))
{
throw new InvalidOperationException(
"This version of the Aspire extension does not support browser debugging. Please update the Aspire extension to use browser debugging support with WithBrowserDebugger().");
}
}
catch (JsonException)
{
// If we can't parse the debug session info, skip validation
}
}
private sealed class DebugSessionCapabilities
{
[JsonPropertyName("supported_launch_configurations")]
public string[]? SupportedLaunchConfigurations { get; set; }
}
private static void AddInstaller(IResourceBuilder resource, bool install) where TResource : JavaScriptAppResource
{
// Only install packages if in run mode
if (resource.ApplicationBuilder.ExecutionContext.IsRunMode)
{
// Check if the installer resource already exists
var installerName = $"{resource.Resource.Name}-installer";
resource.ApplicationBuilder.TryCreateResourceBuilder(installerName, out var existingResource);
if (existingResource is not null)
{
// Installer already exists, update its configuration based on install parameter
if (!install)
{
// Remove wait annotation if install is false
resource.Resource.Annotations.OfType()
.Where(w => w.Resource == existingResource.Resource)
.ToList()
.ForEach(w => resource.Resource.Annotations.Remove(w));
// Add WithExplicitStart to the existing installer
existingResource.WithExplicitStart();
}
return;
}
var installer = new JavaScriptInstallerResource(installerName, resource.Resource.WorkingDirectory);
installer.Annotations.Add(NameValidationPolicyAnnotation.None);
var installerBuilder = resource.ApplicationBuilder.AddResource(installer)
.WithParentRelationship(resource.Resource)
.ExcludeFromManifest()
.WithCertificateTrustScope(CertificateTrustScope.None);
resource.ApplicationBuilder.OnBeforeStart((_, _) =>
{
// set the installer's working directory to match the resource's working directory
// and set the install command and args based on the resource's annotations
if (!resource.Resource.TryGetLastAnnotation(out var packageManager) ||
!resource.Resource.TryGetLastAnnotation(out var installCommand))
{
throw new InvalidOperationException("JavaScriptPackageManagerAnnotation and JavaScriptInstallCommandAnnotation are required when installing packages.");
}
installerBuilder
.WithCommand(packageManager.ExecutableName)
.WithWorkingDirectory(resource.Resource.WorkingDirectory)
.WithArgs(installCommand.Args);
return Task.CompletedTask;
});
if (install)
{
// Make the parent resource wait for the installer to complete
resource.WaitForCompletion(installerBuilder);
}
else
{
// Add WithExplicitStart when install is false
// Note: No need to remove wait annotations here since WaitForCompletion was never called
installerBuilder.WithExplicitStart();
}
resource.WithAnnotation(new JavaScriptPackageInstallerAnnotation(installer));
}
}
private static string GetDefaultBaseImage(string appDirectory, string defaultSuffix, IServiceProvider serviceProvider)
{
var logger = serviceProvider.GetService>() ?? NullLogger.Instance;
var nodeVersion = ResolveNodeVersion(appDirectory, logger);
return $"node:{nodeVersion}-{defaultSuffix}";
}
private static string GetContainerFilesSourcePath(string outputPath)
{
var normalizedPath = NormalizeRelativePath(outputPath);
return string.IsNullOrEmpty(normalizedPath) || normalizedPath == "."
? "/app"
: $"/app/{normalizedPath}";
}
private static readonly string[] s_nextConfigFileNames = ["next.config.ts", "next.config.js", "next.config.mjs"];
///
/// Builds a service discovery URL for the given resource, preferring HTTPS when available.
/// Mirrors the logic in YarpCluster.BuildEndpointUri.
///
private static string BuildServiceDiscoveryUrl(IResourceWithServiceDiscovery resource)
{
var endpoints = resource.GetEndpoints();
var hasHttpsEndpoint = endpoints.Any(e => e.Exists && e.IsHttps);
var hasHttpEndpoint = endpoints.Any(e => e.Exists && e.IsHttp);
var scheme = (hasHttpsEndpoint, hasHttpEndpoint) switch
{
(true, true) => "https+http",
(true, false) => "https",
(false, true) => "http",
_ => throw new ArgumentException("Cannot find a http or https endpoint for this resource.", nameof(resource))
};
return $"{scheme}://{resource.Name}";
}
///
/// Validates that the Next.js config file contains output: "standalone".
///
internal static void ValidateNextJsStandaloneOutput(string appDirectory)
{
foreach (var configFileName in s_nextConfigFileNames)
{
var configPath = Path.Combine(appDirectory, configFileName);
if (!File.Exists(configPath))
{
continue;
}
try
{
var content = File.ReadAllText(configPath);
// Check for quoted "standalone" (double or single quotes) to reduce false positives
if (!content.Contains("\"standalone\"") && !content.Contains("'standalone'"))
{
throw new InvalidOperationException(
$"The Next.js config file '{configFileName}' does not contain 'output: \"standalone\"'. " +
"AddNextJsApp requires Next.js standalone output mode to generate a working Dockerfile. " +
"Add 'output: \"standalone\"' to the nextConfig object in your Next.js config file.");
}
}
catch (IOException)
{
// If we can't read the config, skip the check — the Docker build will surface the error.
}
return;
}
throw new InvalidOperationException(
"No Next.js configuration file found. AddNextJsApp expects one of: " +
string.Join(", ", s_nextConfigFileNames));
}
private static void ValidateApiPath(string apiPath)
{
foreach (var c in apiPath)
{
if (!char.IsAsciiLetterOrDigit(c) && c is not '/' and not '-' and not '_')
{
throw new ArgumentException($"The apiPath must contain only URL-safe path characters (alphanumeric, '/', '-', '_'). Invalid character: '{c}'", nameof(apiPath));
}
}
}
private static string NormalizeRelativePath(string path)
{
var normalizedPath = path.Replace('\\', '/');
if (normalizedPath.StartsWith("./", StringComparison.Ordinal))
{
normalizedPath = normalizedPath[2..];
}
if (normalizedPath.StartsWith('/'))
{
throw new ArgumentException("The path must be a relative path.", nameof(path));
}
// Reject path traversal segments. These are virtual Docker container paths (not host
// filesystem paths), so Path.GetFullPath cannot be used — it produces platform-specific
// results (e.g. D:\app\dist on Windows). Segment-based validation works correctly
// cross-platform for container paths.
var segments = normalizedPath.Split('/', StringSplitOptions.RemoveEmptyEntries);
foreach (var segment in segments)
{
if (segment == "..")
{
throw new ArgumentException("The path must not contain \"..\" segments.", nameof(path));
}
}
return string.Join('/', segments);
}
///
/// Resolves the Node.js version to use for a project by checking common configuration files.
///
/// The working directory of the Node.js project.
/// The logger for diagnostic messages.
/// The resolved Node.js major version number as a string.
private static string ResolveNodeVersion(string workingDirectory, ILogger logger)
{
// Follow the same shape as Cloud Native Buildpacks-style tooling for Node selection:
// pinned toolchain files (.nvmrc, .node-version, .tool-versions) are treated as
// authoritative runtime intent, while package.json engines.node is compatibility
// metadata rather than a deployment image pin. If there is no explicit toolchain pin,
// generated Dockerfiles fall back to Aspire's preferred default Node major.
if (TryDetectPinnedNodeVersion(workingDirectory, logger, out var pinnedNodeVersion))
{
return pinnedNodeVersion;
}
logger.LogDebug("No Node.js version detected, using default version {DefaultVersion}", DefaultNodeVersion);
return DefaultNodeVersion;
}
private static bool TryDetectPinnedNodeVersion(string workingDirectory, ILogger logger, out string nodeVersion)
{
nodeVersion = string.Empty;
// Check .nvmrc file
var nvmrcPath = Path.Combine(workingDirectory, ".nvmrc");
if (File.Exists(nvmrcPath))
{
var versionString = File.ReadAllText(nvmrcPath).Trim();
if (TryParseNodeVersion(versionString, out var version))
{
logger.LogDebug("Detected Node.js version {Version} from .nvmrc file", version);
nodeVersion = version;
return true;
}
}
// Check .node-version file
var nodeVersionPath = Path.Combine(workingDirectory, ".node-version");
if (File.Exists(nodeVersionPath))
{
var versionString = File.ReadAllText(nodeVersionPath).Trim();
if (TryParseNodeVersion(versionString, out var version))
{
logger.LogDebug("Detected Node.js version {Version} from .node-version file", version);
nodeVersion = version;
return true;
}
}
// Check .tool-versions file (asdf)
var toolVersionsPath = Path.Combine(workingDirectory, ".tool-versions");
if (File.Exists(toolVersionsPath))
{
var lines = File.ReadAllLines(toolVersionsPath);
foreach (var line in lines)
{
var trimmedLine = line.Trim();
var parts = trimmedLine.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 1 &&
(string.Equals(parts[0], "nodejs", StringComparison.Ordinal) ||
string.Equals(parts[0], "node", StringComparison.Ordinal)))
{
if (TryParseNodeVersion(parts[1], out var version))
{
logger.LogDebug("Detected Node.js version {Version} from .tool-versions file", version);
nodeVersion = version;
return true;
}
}
}
}
return false;
}
///
/// Attempts to parse a Node.js version string and extract the major version number.
///
/// The version string to parse (e.g., "22", "v22.1.0", ">=20.12", "^18.0.0").
/// The extracted major version number as a string.
/// True if the version was successfully parsed, false otherwise.
private static bool TryParseNodeVersion(string versionString, out string majorVersion)
{
majorVersion = string.Empty;
if (string.IsNullOrWhiteSpace(versionString))
{
return false;
}
// Remove common prefixes and operators (handle multi-character operators first)
var cleaned = versionString.Trim();
string[] operators = [">=", "<=", "==", ">", "<", "=", "~", "^", "v", "V"];
foreach (var op in operators)
{
if (cleaned.StartsWith(op, StringComparison.Ordinal))
{
cleaned = cleaned.Substring(op.Length).TrimStart();
break;
}
}
var cleanedVersion = cleaned.Split('.', '-', ' ')[0]; // Take only the major version part
// Try to parse as integer
if (int.TryParse(cleanedVersion, NumberStyles.None, CultureInfo.InvariantCulture, out var majorVersionNumber) && majorVersionNumber > 0)
{
majorVersion = majorVersionNumber.ToString(CultureInfo.InvariantCulture);
return true;
}
return false;
}
}