// 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
#pragma warning disable ASPIRECOMMAND001
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
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;
/// <summary>
/// Provides extension methods for adding JavaScript applications to an <see cref="IDistributedApplicationBuilder"/>.
/// </summary>
public static partial class JavaScriptHostingExtensions
{
private const string BrowserCapability = "browser";
private const string DefaultNodeVersion = "22";
// Default to the public npm registry so generated Dockerfiles work for customers out of the box.
// Operators who want an internal mirror can override it at build time via `--build-arg NPM_REGISTRY=...`.
// See https://github.com/microsoft/aspire/issues/19370.
private const string DefaultNpmRegistry = "https://registry.npmjs.org/";
private const string DefaultPnpmVersion = "10.30.1";
private const string DefaultJavaScriptRunScriptName = "dev";
private const string DefaultYarpImage = Yarp.YarpContainerImageTags.Registry + "/" + Yarp.YarpContainerImageTags.Image + ":" + Yarp.YarpContainerImageTags.Tag;
// Help links surfaced when a required command is missing, mapped to a command by ResolveHelpLink.
private const string NodeHelpLink = "https://nodejs.org/en/download/";
private const string NpmHelpLink = "https://nodejs.org/en/download";
private const string BunHelpLink = "https://bun.sh/docs/installation";
private const string YarnHelpLink = "https://yarnpkg.com/getting-started/install";
private const string PnpmHelpLink = "https://pnpm.io/installation";
// npm/yarn/pnpm are Node CLIs: whether they install packages or launch the app's run script, they spawn
// node, so node must be on PATH too. bun is a full Node replacement and needs no node.
private static readonly string[] s_nodeBasedPackageManagers = ["npm", "yarn", "pnpm"];
// 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 AspireViteConfigPathToken = "%%ASPIRE_VITE_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_CONFIG_PATH%% is replaced with the relative 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_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
""";
/// <summary>
/// Adds a node application to the application model. Node should be available on the PATH.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The path to the directory containing the node application.</param>
/// <param name="scriptPath">The path to the script relative to the app directory to run.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// This method executes a Node script directly using <c>node script.js</c>. 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 <c>package.json</c> file, npm will be added as the default package manager.
/// </remarks>
/// <example>
/// Add a Node app to the application model using yarn and 'yarn run dev' for running during development:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddNodeApp("frontend", "../frontend", "app.js")
/// .WithYarn()
/// .WithRunScript("dev");
///
/// builder.Build().Run();
/// </code>
/// </example>
[AspireExport]
public static IResourceBuilder<NodeAppResource> 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<JavaScriptRunScriptAnnotation>(out var runCommand) &&
c.Resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(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<string>(() => GetDefaultBaseImage(appDirectory, "alpine", dockerfileContext.Services));
// Get custom base image from annotation, if present
dockerfileContext.Resource.TryGetLastAnnotation<DockerfileBaseImageAnnotation>(out var baseImageAnnotation);
var baseBuildImage = baseImageAnnotation?.BuildImage ?? defaultBaseImage.Value;
var builderStage = dockerfileContext.Builder
.From(baseBuildImage, "build")
.EmptyLine()
.WorkDir("/app");
if (resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(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<JavaScriptInstallCommandAnnotation>(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<JavaScriptBuildScriptAnnotation>(out var buildCommand))
{
var commandArgs = new List<string>() { 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<ILogger<JavaScriptAppResource>>();
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<ContainerFilesDestinationAnnotation>(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, "node");
if (builder.ExecutionContext.IsRunMode)
{
builder.OnBeforeStart((_, _) =>
{
// set the command to the package manager executable if the JavaScriptRunScriptAnnotation is present
if (resourceBuilder.Resource.TryGetLastAnnotation<JavaScriptRunScriptAnnotation>(out _) &&
resourceBuilder.Resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out var packageManager))
{
resourceBuilder.WithCommand(packageManager.ExecutableName);
}
return Task.CompletedTask;
});
}
return resourceBuilder;
}
private static IResourceBuilder<TResource> WithNodeDefaults<TResource>(this IResourceBuilder<TResource> builder) where TResource : JavaScriptAppResource =>
builder.WithOtlpExporter()
.WithRequiredCommandsFromPackageManager("node")
.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;
});
// Registers a hook that materializes the resource's required commands just before start. The annotations are
// added on BeforeStartEvent in every execution context, but they only have an effect in run mode, where
// RequiredCommandValidationEventingSubscriber validates them against the local PATH on
// BeforeResourceStartedEvent (which fires after BeforeStartEvent). Resolving them here - rather than eagerly
// as each With* method runs - lets the package-manager selection settle first, so a later selection fully
// replaces an earlier one without having to remove stale RequiredCommandAnnotations.
// See https://github.com/microsoft/aspire/issues/18625.
//
// runtimeCommand is the executable the app was created to run with (node for
// AddNodeApp/AddViteApp/AddJavaScriptApp, bun for AddBunApp); it launches the app whenever the app is not
// routed through a package-manager run script.
private static IResourceBuilder<TResource> WithRequiredCommandsFromPackageManager<TResource>(
this IResourceBuilder<TResource> builder,
string runtimeCommand) where TResource : JavaScriptAppResource
{
var resource = builder.Resource;
builder.ApplicationBuilder.OnBeforeStart((_, _) =>
{
foreach (var (command, helpLink) in ResolveRequiredCommands(resource, runtimeCommand))
{
// Idempotent: skip commands already present so an unexpected second publish of BeforeStartEvent
// cannot add duplicate RequiredCommandAnnotations for the same command.
if (!resource.Annotations.OfType<RequiredCommandAnnotation>().Any(a => string.Equals(a.Command, command, StringComparison.Ordinal)))
{
resource.Annotations.Add(new RequiredCommandAnnotation(command) { HelpLink = helpLink });
}
}
return Task.CompletedTask;
});
return builder;
}
// Resolves the executables that must be on PATH for the app to install and run, from how the app is actually
// launched. Two independent axes:
// - Runtime: apps that launch via a named package-manager run script (npm run dev / bun run dev) - which is
// every AddViteApp/AddJavaScriptApp, plus AddNodeApp/AddBunApp when WithRunScript is used - are launched by
// the package manager, so the package manager is the runtime. Apps that invoke a script file directly
// (AddNodeApp "server.js" / AddBunApp "server.ts" with no run script) are launched by their fixed runtime
// (node/bun) regardless of any package manager.
// - Install: a selected package manager also runs at install time, so it must be on PATH even when a
// different runtime launches the app - e.g. AddNodeApp(...).WithBun() runs `node server.js` but installs
// with `bun`, so both node and bun are required.
// npm/yarn/pnpm additionally require node (they are Node CLIs); bun does not. This projection is what fixes
// https://github.com/microsoft/aspire/issues/18625 (AddViteApp(...).WithBun() requires only bun) without
// dropping the runtime for direct-script apps.
private static IEnumerable<(string Command, string? HelpLink)> ResolveRequiredCommands(IResource resource, string runtimeCommand)
{
resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out var packageManager);
// A package manager only replaces the runtime when the app launches through a run script; otherwise the
// runtime executes the script file directly.
var launchesViaRunScript = resource.TryGetLastAnnotation<JavaScriptRunScriptAnnotation>(out _);
var runCommand = launchesViaRunScript && packageManager is not null
? packageManager.ExecutableName
: runtimeCommand;
var commands = new HashSet<string>(StringComparer.Ordinal) { runCommand };
if (packageManager is not null)
{
commands.Add(packageManager.ExecutableName);
}
if (commands.Overlaps(s_nodeBasedPackageManagers))
{
commands.Add("node");
}
return commands.Select(static command => (command, ResolveHelpLink(command)));
}
// Maps a required executable to the install/help link surfaced when the command is missing on PATH.
private static string? ResolveHelpLink(string command) => command switch
{
"node" => NodeHelpLink,
"npm" => NpmHelpLink,
"bun" => BunHelpLink,
"yarn" => YarnHelpLink,
"pnpm" => PnpmHelpLink,
// Unknown/custom package manager: no specific install help link.
_ => null,
};
// 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 <contextRoot>/.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
""";
/// <summary>
/// Adds a Bun application to the application model. Bun should be available on the PATH.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The path to the directory containing the Bun application.</param>
/// <param name="scriptPath">The path to the script (for example, <c>server.ts</c>) relative to <paramref name="appDirectory"/> to run.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// This method executes the script directly using <c>bun <script></c>. Bun natively runs JavaScript and TypeScript
/// files so no transpile step is required.
///
/// If the application directory contains a <c>package.json</c> file, Bun will be added as the default package manager.
/// When publishing to a container, the default base image is <c>oven/bun:1</c> for both the build and runtime stages.
/// </remarks>
/// <example>
/// Add a Bun app to the application model:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddBunApp("api", "../api", "server.ts");
///
/// builder.Build().Run();
/// </code>
/// </example>
[AspireExport]
public static IResourceBuilder<BunAppResource> 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<JavaScriptRunScriptAnnotation>(out var runCommand) &&
c.Resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(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<DockerfileBaseImageAnnotation>(out var baseImageAnnotation);
// Provide a default .dockerignore that publishers emit alongside the generated
// Dockerfile using BuildKit's per-Dockerfile ignore convention
// (<dockerfile-name>.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<DockerfileBuildAnnotation>(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<JavaScriptPackageManagerAnnotation>(out var packageManager))
{
// Initialize the Docker build stage with package manager-specific setup commands.
packageManager.InitializeDockerBuildStage?.Invoke(builderStage);
var copiedAllSource = false;
if (resource.TryGetLastAnnotation<JavaScriptInstallCommandAnnotation>(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<JavaScriptBuildScriptAnnotation>(out var buildCommand))
{
var commandArgs = new List<string>() { 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<ILogger<JavaScriptAppResource>>();
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<JavaScriptPackageManagerAnnotation>(out prodPackageManager) &&
resource.TryGetLastAnnotation<JavaScriptInstallCommandAnnotation>(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
// <dockerfile-name>.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<ContainerFilesDestinationAnnotation>(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();
}
resourceBuilder.WithVSCodeDebugging(scriptPath, "bun");
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<JavaScriptRunScriptAnnotation>(out _) &&
resourceBuilder.Resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out var packageManager))
{
resourceBuilder.WithCommand(packageManager.ExecutableName);
}
return Task.CompletedTask;
});
}
return resourceBuilder;
}
private static IResourceBuilder<TResource> WithBunDefaults<TResource>(this IResourceBuilder<TResource> builder) where TResource : JavaScriptAppResource =>
builder.WithOtlpExporter()
.WithRequiredCommandsFromPackageManager("bun")
// 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;
});
/// <summary>
/// Adds a JavaScript application resource to the distributed application using the specified app directory and
/// run script.
/// </summary>
/// <param name="builder">The distributed application builder to which the JavaScript application resource will be added.</param>
/// <param name="name">The unique name of the JavaScript application resource. Cannot be null or empty.</param>
/// <param name="appDirectory">The path to the directory containing the JavaScript application.</param>
/// <param name="runScriptName">The name of the npm script to run when starting the application. Defaults to "dev". Cannot be null or empty.</param>
/// <returns>A resource builder for the newly added JavaScript application resource.</returns>
/// <remarks>
/// 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.
/// </remarks>
[AspireExport]
public static IResourceBuilder<JavaScriptAppResource> 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);
}
/// <summary>
/// Configures the JavaScript application to publish as a standalone static website served by YARP.
/// </summary>
/// <typeparam name="TResource">The JavaScript resource type.</typeparam>
/// <param name="builder">The JavaScript resource builder.</param>
/// <param name="configure">Optional callback to configure <see cref="PublishAsStaticWebsiteOptions"/>.</param>
/// <returns>The updated resource builder.</returns>
/// <remarks>
/// <para>
/// 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 <c>apiPath</c> and <c>apiTarget</c>.
/// </para>
/// </remarks>
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExportIgnore(Reason = "Use the polyglot-compatible overload instead.")]
public static IResourceBuilder<TResource> PublishAsStaticWebsite<TResource>(
this IResourceBuilder<TResource> builder,
Action<PublishAsStaticWebsiteOptions>? configure = null)
where TResource : JavaScriptAppResource
{
var options = new PublishAsStaticWebsiteOptions();
configure?.Invoke(options);
return PublishAsStaticWebsiteCore(builder, null, null, options);
}
/// <summary>
/// Configures the JavaScript application to publish as a standalone static website served by YARP,
/// with an API reverse-proxy to the specified resource.
/// </summary>
/// <typeparam name="TResource">The JavaScript resource type.</typeparam>
/// <param name="builder">The JavaScript resource builder.</param>
/// <param name="apiPath">
/// A path prefix to reverse-proxy to a backend API. For example, <c>/api</c> proxies all requests
/// matching <c>/api/{"{**catch-all}"}</c> to the backend resource.
/// </param>
/// <param name="apiTarget">
/// The backend resource to proxy API requests to. YARP uses service discovery to resolve the
/// appropriate endpoint, preferring HTTPS when available.
/// </param>
/// <param name="configure">Optional callback to configure <see cref="PublishAsStaticWebsiteOptions"/>.</param>
/// <returns>The updated resource builder.</returns>
/// <remarks>
/// <para>
/// 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.).
/// </para>
/// </remarks>
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExportIgnore(Reason = "Use the polyglot-compatible overload instead.")]
public static IResourceBuilder<TResource> PublishAsStaticWebsite<TResource>(
this IResourceBuilder<TResource> builder,
string apiPath,
IResourceBuilder<IResourceWithServiceDiscovery> apiTarget,
Action<PublishAsStaticWebsiteOptions>? 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
/// <summary>
/// Publishes the JavaScript application as a standalone static website using YARP.
/// </summary>
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport("publishAsStaticWebsite")]
internal static IResourceBuilder<TResource> PublishAsStaticWebsitePolyglot<TResource>(
#pragma warning restore ASPIREEXPORT009
this IResourceBuilder<TResource> builder,
string? apiPath = null,
IResourceBuilder<IResourceWithServiceDiscovery>? 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<TResource> PublishAsStaticWebsiteCore<TResource>(
IResourceBuilder<TResource> builder,
string? apiPath,
IResourceBuilder<IResourceWithServiceDiscovery>? 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<DockerfileBuildAnnotation>(out var dockerfileBuildAnnotation))
{
dockerfileBuildAnnotation.HasEntrypoint = true;
}
return builder;
}
/// <summary>
/// Configures the JavaScript application to publish as a standalone Node.js server that runs a built artifact directly.
/// </summary>
/// <typeparam name="TResource">The JavaScript resource type.</typeparam>
/// <param name="builder">The JavaScript resource builder.</param>
/// <param name="entryPoint">
/// The relative path to the Node.js entry point to execute in the published container after the build completes,
/// such as <c>.output/server/index.mjs</c> or <c>build/index.js</c>.
/// </param>
/// <param name="outputPath">
/// The relative path containing the built runtime files to copy into the published container. Defaults to the application root.
/// </param>
/// <returns>The updated resource builder.</returns>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// The container files source path is automatically set to <paramref name="outputPath"/> so that only
/// the built output directory is copied into the runtime container, not the full application source.
/// </para>
/// </remarks>
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public static IResourceBuilder<TResource> PublishAsNodeServer<TResource>(this IResourceBuilder<TResource> 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<DockerfileBuildAnnotation>(out var dockerfileBuildAnnotation))
{
dockerfileBuildAnnotation.HasEntrypoint = true;
}
return builder;
}
/// <summary>
/// Configures the JavaScript application to publish as a Node.js server that uses a <c>package.json</c> script at runtime.
/// </summary>
/// <typeparam name="TResource">The JavaScript resource type.</typeparam>
/// <param name="builder">The JavaScript resource builder.</param>
/// <param name="scriptName">
/// The name of the <c>package.json</c> script to run in the published container.
/// For example, <c>start</c> invokes the configured package manager's run command for the <c>start</c> script,
/// such as <c>npm run start</c>, <c>pnpm run start</c>, <c>yarn run start</c>, or <c>bun run start</c>.
/// </param>
/// <param name="runScriptArguments">
/// Optional arguments appended after the script name at runtime,
/// such as <c>-- --port "$PORT"</c>.
/// </param>
/// <returns>The updated resource builder.</returns>
/// <remarks>
/// <para>
/// Use this method for frameworks where the production server depends on packages in <c>node_modules</c> at runtime.
/// The resulting container includes the full application with production dependencies installed.
/// </para>
/// <para>
/// This method is appropriate for frameworks like Nuxt (where <c>useAsyncData</c>/<c>useFetch</c> requires the
/// full Nitro environment), Remix (where <c>react-router-serve</c> is an npm dependency), and Astro SSR
/// (where the built entry point imports unbundled <c>@astrojs/*</c> packages).
/// </para>
/// <para>
/// For frameworks that produce a self-contained server artifact that does not require <c>node_modules</c>,
/// use <see cref="PublishAsNodeServer{TResource}"/> instead for a smaller runtime image.
/// </para>
/// </remarks>
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public static IResourceBuilder<TResource> PublishAsPackageScript<TResource>(this IResourceBuilder<TResource> 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<DockerfileBuildAnnotation>(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<TResource> CreateDefaultJavaScriptAppBuilder<TResource>(
this IDistributedApplicationBuilder builder,
TResource resource,
string appDirectory,
string runScriptName,
Action<CommandLineArgsCallbackContext>? argsCallback = null) where TResource : JavaScriptAppResource
{
var resourceBuilder = builder.AddResource(resource)
.WithNodeDefaults()
.WithArgs(c =>
{
if (c.Resource.TryGetLastAnnotation<JavaScriptRunScriptAnnotation>(out var runCommand))
{
if (c.Resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(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<JavaScriptPublishModeAnnotation>(out var publishMode);
if (c.Resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out var packageManager))
{
// Get custom base image from annotation, if present
dockerfileContext.Resource.TryGetLastAnnotation<DockerfileBaseImageAnnotation>(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<JavaScriptInstallCommandAnnotation>(out var installCommand))
{
dockerBuilder.AddInstallCommand(packageManager, installCommand);
}
if (!copiedAllSource)
{
// Copy application source code after dependencies are installed
dockerBuilder.Copy(".", ".");
}
if (c.Resource.TryGetLastAnnotation<JavaScriptBuildScriptAnnotation>(out var buildCommand))
{
var commandArgs = new List<string>() { 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<JavaScriptInstallCommandAnnotation>(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<DockerfileBuildAnnotation>(out var dockerFileAnnotation))
{
dockerFileAnnotation.HasEntrypoint =
resource.TryGetLastAnnotation<JavaScriptPublishModeAnnotation>(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<ContainerResource>(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<JavaScriptPackageManagerAnnotation>(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<DockerfileBuildAnnotation>(out var dockerfileBuildAnnotation) ||
dockerfileBuildAnnotation.DockerfileFactory is not null ||
!containerResource.TryGetLastAnnotation<JavaScriptRunScriptAnnotation>(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.");
}
/// <summary>
/// Adds a Vite app to the distributed application builder.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the Vite app.</param>
/// <param name="appDirectory">The path to the directory containing the Vite app.</param>
/// <param name="runScriptName">The name of the script that runs the Vite app. Defaults to "dev".</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// <example>
/// The following example creates a Vite app using npm as the package manager.
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddViteApp("frontend", "./frontend");
///
/// builder.Build().Run();
/// </code>
/// </example>
/// </remarks>
[AspireExport]
public static IResourceBuilder<ViteAppResource> 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 appHostId = builder.Configuration["AppHost:Sha256"]![..10].ToLowerInvariant();
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<JavaScriptPackageManagerAnnotation>(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("--", StringComparison.Ordinal) => 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);
// Find the nearest node_modules directory by walking up from the app directory.
// This handles package managers that hoist dependencies (e.g. yarn workspaces)
// where node_modules lives at the repo root rather than in the app directory.
// Writing inside node_modules ensures Node.js module resolution can find
// bare imports like 'vite' in the generated wrapper config.
var nodeModulesDir = FindNearestNodeModules(appDirectory);
if (nodeModulesDir is null)
{
var resourceLoggerService = ctx.ExecutionContext.Services.GetRequiredService<ResourceLoggerService>();
var resourceLogger = resourceLoggerService.GetLogger(resource);
resourceLogger.LogWarning("Could not find a node_modules directory in or above '{AppDirectory}' for resource '{ResourceName}'. Automatic HTTPS configuration won't be available. Ensure packages are installed before starting the app.", appDirectory, resource.Name);
ctx.Arguments.Add("--config");
ctx.Arguments.Add(configTarget);
return;
}
// Use the same per-AppHost discriminator as persistent resource names so concurrent
// AppHosts sharing a hoisted node_modules directory cannot overwrite each other's wrappers.
var aspireConfigDir = Path.Join(nodeModulesDir, ".aspire", appHostId, resource.Name);
Directory.CreateDirectory(aspireConfigDir);
// Compute the relative path from the wrapper location to the original config
var relativeConfigPath = Path.GetRelativePath(aspireConfigDir, absoluteConfigPath).Replace("\\", "/");
// Generate an Aspire specific Vite config file that wraps the user's original config with HTTPS support
var aspireConfig = AspireViteConfig
.Replace(AspireViteConfigPathToken, relativeConfigPath, StringComparison.Ordinal)
.Replace(AspireViteAbsoluteConfigToken, absoluteConfigPath.Replace("\\", "\\\\"), StringComparison.Ordinal);
var aspireConfigPath = Path.Join(aspireConfigDir, $"aspire.{Path.GetFileName(configTarget)}");
File.WriteAllText(aspireConfigPath, aspireConfig);
// Override the path to the Vite config file to use the Aspire generated one
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.Services.GetRequiredService<ResourceLoggerService>();
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. The user explicitly opted into HTTPS via WithHttpsDeveloperCertificate(), so the scheme
// change is unconditional here.
resourceBuilder.SubscribeHttpsEndpointsUpdate(ctx =>
{
resourceBuilder.WithEndpoint("http", ep => ep.UriScheme = "https");
});
}
return resourceBuilder;
}
/// <summary>
/// Adds a Next.js app to the distributed application builder.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the Next.js app.</param>
/// <param name="appDirectory">The path to the directory containing the Next.js app.</param>
/// <param name="runScriptName">The name of the script that runs the Next.js dev server. Defaults to "dev".</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// <para>
/// 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 <c>public/</c>, <c>.next/standalone/</c>, and <c>.next/static/</c> into a
/// Node.js runtime container.
/// </para>
/// <para>
/// The Next.js application must have <c>output: "standalone"</c> configured in <c>next.config.ts</c>
/// and a <c>public/</c> directory (even if empty) for the published container to build correctly.
/// </para>
/// <example>
/// The following example creates a Next.js app.
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddNextJsApp("frontend", "./frontend");
///
/// builder.Build().Run();
/// </code>
/// </example>
/// </remarks>
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public static IResourceBuilder<NextJsAppResource> 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<JavaScriptPackageManagerAnnotation>(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<DockerfileBuildAnnotation>(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<SuppressPublishValidationAnnotation>(out var suppress))
{
ValidateNextJsStandaloneOutput(appDirectory);
}
return Task.CompletedTask;
}
}
]));
return resourceBuilder;
}
/// <summary>
/// Disables deploy-time build validation checks for the Next.js application.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <returns>The resource builder for chaining.</returns>
/// <remarks>
/// By default, <see cref="AddNextJsApp"/> adds publish prerequisite steps that verify
/// the Next.js configuration (e.g. that <c>output: "standalone"</c> 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.
/// </remarks>
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public static IResourceBuilder<NextJsAppResource> DisableBuildValidation(this IResourceBuilder<NextJsAppResource> builder)
{
return builder.WithAnnotation<SuppressPublishValidationAnnotation>(new());
}
/// <summary>
/// Configures the Vite app to use the specified Vite configuration file instead of the default resolution behavior.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="configPath">The path to the Vite configuration file. Relative to the Vite service project root.</param>
/// <returns>The resource builder.</returns>
/// <remarks>
/// Use this method to specify a specific Vite configuration file if you need to override the default Vite configuration resolution behavior.
/// </remarks>
/// <example>
/// Use a custom Vite configuration file:
/// <code>
/// var builder = DistributedApplication.CreateBuilder(args);
/// var viteApp = builder.AddViteApp("frontend", "./frontend")
/// .WithViteConfig("./vite.production.config.js");
/// </code>
/// </example>
[AspireExport]
public static IResourceBuilder<ViteAppResource> WithViteConfig(this IResourceBuilder<ViteAppResource> builder, string configPath)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(configPath);
builder.Resource.ViteConfigPath = configPath;
return builder;
}
/// <summary>
/// Configures the Node.js resource to use npm as the package manager and optionally installs packages before the application starts.
/// </summary>
/// <param name="resource">The NodeAppResource.</param>
/// <param name="install">When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource.</param>
/// <param name="installCommand">The install command itself passed to npm to install dependencies.</param>
/// <param name="installArgs">The command-line arguments passed to npm to install dependencies.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <ats-returns>The resource builder.</ats-returns>
[AspireExport]
public static IResourceBuilder<TResource> WithNpm<TResource>(this IResourceBuilder<TResource> 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"
});
AddInstaller(resource, install);
return resource;
}
/// <summary>
/// Configures the JavaScript resource to use Bun as the package manager and optionally installs packages before the application starts.
/// </summary>
/// <param name="resource">The JavaScript application resource builder.</param>
/// <param name="install">When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource.</param>
/// <param name="installArgs">Additional command-line arguments passed to "bun install". When null, defaults are applied based on publish mode and lockfile presence.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// Bun forwards script arguments without requiring the <c>--</c> command separator, so this method configures the resource to omit it.
/// When publishing and a bun lockfile (<c>bun.lock</c> or <c>bun.lockb</c>) is present, <c>--frozen-lockfile</c> 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.
/// <see cref="PublishAsPackageScript{TResource}"/> 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, <c>oven/bun:<tag></c>) using <see cref="ContainerResourceBuilderExtensions.WithDockerfileBaseImage{T}(IResourceBuilder{T}, string?, string?)"/>.
/// </remarks>
/// <ats-remarks />
/// <example>
/// Run a Vite app using Bun as the package manager:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddViteApp("frontend", "./frontend")
/// .WithBun()
/// .WithDockerfileBaseImage(buildImage: "oven/bun:latest"); // To use a specific Bun image
///
/// builder.Build().Run();
/// </code>
/// </example>
[AspireExport]
public static IResourceBuilder<TResource> WithBun<TResource>(this IResourceBuilder<TResource> 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"
});
if (!resource.Resource.TryGetLastAnnotation<DockerfileBaseImageAnnotation>(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<JavaScriptAppResource> resource, bool hasBunLock) =>
resource.ApplicationBuilder.ExecutionContext.IsPublishMode && hasBunLock
? ["--frozen-lockfile"]
: [];
private static string GetDefaultNpmInstallCommand(IResourceBuilder<JavaScriptAppResource> resource) =>
resource.ApplicationBuilder.ExecutionContext.IsPublishMode &&
File.Exists(Path.Combine(resource.Resource.WorkingDirectory, "package-lock.json"))
? "ci"
: "install";
/// <summary>
/// Configures the Node.js resource to use yarn as the package manager and optionally installs packages before the application starts.
/// </summary>
/// <param name="resource">The NodeAppResource.</param>
/// <param name="install">When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource.</param>
/// <param name="installArgs">The command-line arguments passed to "yarn install".</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <ats-returns>The resource builder.</ats-returns>
[AspireExport]
public static IResourceBuilder<TResource> WithYarn<TResource>(this IResourceBuilder<TResource> 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"
});
AddInstaller(resource, install);
return resource;
}
private static string[] GetDefaultYarnInstallArgs(
IResourceBuilder<JavaScriptAppResource> 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"];
}
/// <summary>
/// Configures the Node.js resource to use pnpm as the package manager and optionally installs packages before the application starts.
/// </summary>
/// <param name="resource">The NodeAppResource.</param>
/// <param name="install">When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource.</param>
/// <param name="installArgs">The command-line arguments passed to "pnpm install".</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown when <c>package.json</c> declares an invalid pnpm package manager version or integrity.</exception>
/// <ats-returns>The resource builder.</ats-returns>
[AspireExport]
public static IResourceBuilder<TResource> WithPnpm<TResource>(this IResourceBuilder<TResource> 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"));
var pnpmPackageManager = GetPnpmPackageManager(workingDirectory);
var initializeDockerStage = new Action<DockerfileStage>(stage =>
{
stage.Arg("NPM_REGISTRY", DefaultNpmRegistry);
if (pnpmPackageManager.Integrity is { } integrity)
{
stage.Run($"archive=\"$(npm pack --json pnpm@{pnpmPackageManager.Version} --registry \"$NPM_REGISTRY\" | node -e 'const result = JSON.parse(require(\"fs\").readFileSync(0, \"utf8\")); process.stdout.write(result[0].filename)')\" && node -e 'const [algorithm, expected, file] = process.argv.slice(1); const actual = require(\"crypto\").createHash(algorithm).update(require(\"fs\").readFileSync(file)).digest(\"hex\"); if (actual !== expected) {{ console.error(\"Integrity check failed for \" + file); process.exit(1); }}' \"{integrity.Algorithm}\" \"{integrity.Hash}\" \"$archive\" && npm install --global --registry \"$NPM_REGISTRY\" \"./$archive\" && rm \"$archive\"");
}
else
{
stage.Run($"npm install --global --registry \"$NPM_REGISTRY\" pnpm@{pnpmPackageManager.Version}");
}
});
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.
InitializeDockerBuildStage = initializeDockerStage,
InitializeDockerRuntimeStage = initializeDockerStage,
})
.WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])
{
ProductionInstallArgs = "--prod"
});
AddInstaller(resource, install);
return resource;
}
private static string[] GetDefaultPnpmInstallArgs(IResourceBuilder<JavaScriptAppResource> resource, bool hasPnpmLock) =>
resource.ApplicationBuilder.ExecutionContext.IsPublishMode && hasPnpmLock
? ["--frozen-lockfile"]
: [];
private static (string Version, (string Algorithm, string Hash)? Integrity) GetPnpmPackageManager(string workingDirectory)
{
var packageJsonPath = Path.Combine(workingDirectory, "package.json");
if (!File.Exists(packageJsonPath))
{
return (DefaultPnpmVersion, null);
}
try
{
using var packageJson = JsonDocument.Parse(File.ReadAllText(packageJsonPath));
if (packageJson.RootElement.TryGetProperty("packageManager", out var packageManagerElement) &&
packageManagerElement.ValueKind == JsonValueKind.String &&
packageManagerElement.GetString() is { } packageManager &&
packageManager.StartsWith("pnpm@", StringComparison.Ordinal))
{
var version = packageManager.AsSpan("pnpm@".Length);
ReadOnlySpan<char> integrity = default;
var hashSeparator = version.IndexOf('+');
var hasIntegrity = hashSeparator >= 0;
if (hasIntegrity)
{
integrity = version[(hashSeparator + 1)..];
version = version[..hashSeparator];
}
if (PnpmVersionRegex().IsMatch(version))
{
var integritySeparator = integrity.IndexOf('.');
if (integritySeparator > 0 &&
integrity[(integritySeparator + 1)..] is { IsEmpty: false } hash &&
hash.IndexOfAnyExcept("0123456789abcdefABCDEF") < 0 &&
integrity[..integritySeparator] is "sha224" or "sha256" or "sha384" or "sha512")
{
return (version.ToString(), (integrity[..integritySeparator].ToString(), hash.ToString().ToLowerInvariant()));
}
if (!hasIntegrity)
{
return (version.ToString(), null);
}
}
// A declared pnpm specification controls the binary installed in the published image.
// Fail closed instead of silently discarding the requested version and integrity.
throw new InvalidOperationException(
$"The packageManager value '{packageManager}' in '{packageJsonPath}' is invalid. Expected 'pnpm@<version>' or 'pnpm@<version>+<sha224|sha256|sha384|sha512>.<hex hash>'.");
}
}
catch (JsonException)
{
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
return (DefaultPnpmVersion, null);
}
// Corepack requires packageManager values to use an exact semantic version. node-semver
// also accepts the ecosystem's conventional leading "v"; integrity metadata is parsed
// separately after the version's '+' delimiter.
// See https://github.com/nodejs/corepack/blob/436b358a19f6d2592cff740078db1b06953c3578/sources/specUtils.ts
[GeneratedRegex("""^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$""", RegexOptions.CultureInvariant | RegexOptions.NonBacktracking)]
private static partial Regex PnpmVersionRegex();
/// <summary>
/// Adds a build script annotation to the resource builder using the specified command-line arguments.
/// </summary>
/// <typeparam name="TResource">The type of JavaScript application resource being configured.</typeparam>
/// <param name="resource">The resource builder to which the build script annotation will be added.</param>
/// <param name="scriptName">The name of the script to be executed when the resource is built.</param>
/// <param name="args">An array of command-line arguments to use for the build script.</param>
/// <returns>The same resource builder instance with the build script annotation applied.</returns>
/// <remarks>
/// Use this method to specify custom build scripts for JavaScript application resources during
/// deployment.
/// </remarks>
[AspireExport]
public static IResourceBuilder<TResource> WithBuildScript<TResource>(this IResourceBuilder<TResource> resource, string scriptName, string[]? args = null) where TResource : JavaScriptAppResource
{
return resource.WithAnnotation(new JavaScriptBuildScriptAnnotation(scriptName, args));
}
/// <summary>
/// Adds a run script annotation to the specified JavaScript application resource builder, specifying the script to
/// execute and its arguments during run mode.
/// </summary>
/// <typeparam name="TResource">The type of the JavaScript application resource being configured. Must inherit from JavaScriptAppResource.</typeparam>
/// <param name="resource">The resource builder to which the run script annotation will be added.</param>
/// <param name="scriptName">The name of the script to be executed when the resource is run.</param>
/// <param name="args">An array of arguments to pass to the script.</param>
/// <returns>The same resource builder instance with the run script annotation applied, enabling further configuration.</returns>
/// <remarks>
/// Use this method to specify a custom script and its arguments that should be executed when the resource is executed
/// in RunMode.
/// </remarks>
[AspireExport]
public static IResourceBuilder<TResource> WithRunScript<TResource>(this IResourceBuilder<TResource> 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<T> WithVSCodeDebugging<T>(this IResourceBuilder<T> builder, string scriptPath, string launchConfigType)
where T : JavaScriptAppResource
{
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<JavaScriptRunScriptAnnotation>(out _);
var hasPackageManager = resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out var pmAnnotation);
var isPackageManagerScript = hasRunScript && hasPackageManager;
return new JavaScriptLaunchConfiguration(launchConfigType)
{
ScriptPath = Path.GetFullPath(scriptPath, workingDirectory),
Mode = mode,
RuntimeExecutable = isPackageManagerScript ? pmAnnotation!.ExecutableName : launchConfigType,
LaunchMethod = isPackageManagerScript ? JavaScriptLaunchConfiguration.LaunchMethodPackageManager : JavaScriptLaunchConfiguration.LaunchMethodDirect,
WorkingDirectory = workingDirectory
};
},
launchConfigType);
}
[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
internal static IResourceBuilder<T> WithVSCodeDebugging<T>(this IResourceBuilder<T> builder)
where T : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(builder);
var resource = builder.Resource;
var workingDirectory = Path.GetFullPath(resource.WorkingDirectory);
if (resource is BunAppResource)
{
throw new InvalidOperationException(
$"Bun apps cannot be debugged through the Node dev-server debug path. '{resource.Name}' is a {nameof(BunAppResource)}; use {nameof(AddBunApp)}, which wires its own Bun debug support.");
}
return builder.WithDebugSupport(
mode =>
{
// Fall back to "npm" (the default for these frameworks) if no package manager annotation is present.
var packageManager = "npm";
if (resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out var pmAnnotation))
{
packageManager = pmAnnotation.ExecutableName;
}
return new JavaScriptLaunchConfiguration("node")
{
ScriptPath = string.Empty,
Mode = mode,
RuntimeExecutable = packageManager,
LaunchMethod = JavaScriptLaunchConfiguration.LaunchMethodPackageManager,
WorkingDirectory = workingDirectory
};
},
"node");
}
/// <summary>
/// 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.
/// </summary>
/// <typeparam name="T">The type of the JavaScript application resource.</typeparam>
/// <param name="builder">The resource builder for the JavaScript application.</param>
/// <param name="browser">The browser to use for debugging. Defaults to <c>"msedge"</c>. Supported values include <c>"msedge"</c> and <c>"chrome"</c>.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining additional configuration.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// This method creates a child <see cref="BrowserDebuggerResource"/> 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.
/// </remarks>
/// <exception cref="InvalidOperationException">
/// Thrown when the parent resource does not have an HTTP or HTTPS endpoint, or when the IDE extension
/// does not support browser debugging.
/// </exception>
/// <example>
/// Add browser debugging to a JavaScript application:
/// <code>
/// var builder = DistributedApplication.CreateBuilder(args);
/// builder.AddViteApp("frontend", "./frontend")
/// .WithBrowserDebugger();
/// </code>
/// </example>
[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public static IResourceBuilder<T> WithBrowserDebugger<T>(
this IResourceBuilder<T> 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<EndpointAnnotation>(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<T>(IResourceBuilder<T> builder) where T : IResource
{
var configuration = builder.ApplicationBuilder.Configuration;
try
{
if (configuration["DEBUG_SESSION_INFO"] is { } debugSessionInfoJson
&& JsonSerializer.Deserialize<DebugSessionCapabilities>(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<TResource>(IResourceBuilder<TResource> 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<JavaScriptInstallerResource>(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<WaitAnnotation>()
.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<JavaScriptPackageManagerAnnotation>(out var packageManager) ||
!resource.Resource.TryGetLastAnnotation<JavaScriptInstallCommandAnnotation>(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<ILogger<JavaScriptAppResource>>() ?? NullLogger<JavaScriptAppResource>.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"];
/// <summary>
/// Builds a service discovery URL for the given resource, preferring HTTPS when available.
/// Mirrors the logic in <c>YarpCluster.BuildEndpointUri</c>.
/// </summary>
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}";
}
/// <summary>
/// Validates that the Next.js config file contains <c>output: "standalone"</c>.
/// </summary>
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));
}
}
}
/// <summary>
/// Walks up from <paramref name="startDirectory"/> to find the nearest <c>node_modules</c> directory.
/// </summary>
private static string? FindNearestNodeModules(string startDirectory)
{
var current = Path.GetFullPath(startDirectory);
while (current is not null)
{
var candidate = Path.Join(current, "node_modules");
if (Directory.Exists(candidate))
{
return candidate;
}
var parent = Path.GetDirectoryName(current);
if (parent == current)
{
break;
}
current = parent;
}
return null;
}
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);
}
/// <summary>
/// Resolves the Node.js version to use for a project by checking common configuration files.
/// </summary>
/// <param name="workingDirectory">The working directory of the Node.js project.</param>
/// <param name="logger">The logger for diagnostic messages.</param>
/// <returns>The resolved Node.js major version number as a string.</returns>
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;
}
/// <summary>
/// Attempts to parse a Node.js version string and extract the major version number.
/// </summary>
/// <param name="versionString">The version string to parse (e.g., "22", "v22.1.0", ">=20.12", "^18.0.0").</param>
/// <param name="majorVersion">The extracted major version number as a string.</param>
/// <returns>True if the version was successfully parsed, false otherwise.</returns>
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;
}
}