// 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 ASPIREDENO001 // AddDenoApp and its implementation use the experimental Deno resource
#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;
///
/// Provides extension methods for adding JavaScript applications to an .
///
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 DenoHelpLink = "https://docs.deno.com/runtime/getting_started/installation/";
private const string YarnHelpLink = "https://yarnpkg.com/getting-started/install";
private const string PnpmHelpLink = "https://pnpm.io/installation";
private const string DenoDefaultUser = "deno";
private const string DenoDefaultUserAndGroup = "deno:deno";
// Deno's dependency store. Pinned to a known path so multi-stage builds can copy it from the
// build stage into the runtime stage. See https://docs.deno.com/runtime/reference/docker/.
private const string DenoCacheDirectory = "/deno-dir";
// 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
""";
///
/// Adds a node application to the application model. Node should be available on the PATH.
///
/// The to add the resource to.
/// The name of the resource.
/// The path to the directory containing the node application.
/// The path to the script relative to the app directory to run.
/// A reference to the .
/// The resource builder.
///
/// This method executes a Node script directly using node script.js. If you want to use a package manager
/// you can add one and configure the install and run scripts using the provided extension methods.
///
/// If the application directory contains a package.json file, npm will be added as the default package manager.
///
///
/// Add a Node app to the application model using yarn and 'yarn run dev' for running during development:
///
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddNodeApp("frontend", "../frontend", "app.js")
/// .WithYarn()
/// .WithRunScript("dev");
///
/// builder.Build().Run();
///
///
[AspireExport]
public static IResourceBuilder AddNodeApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string scriptPath)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(scriptPath);
appDirectory = Path.GetFullPath(appDirectory, builder.AppHostDirectory);
var resource = new NodeAppResource(name, "node", appDirectory);
var resourceBuilder = builder.AddResource(resource)
.WithNodeDefaults()
.WithArgs(c =>
{
// If the JavaScriptRunScriptAnnotation is present, use that to run the app
if (c.Resource.TryGetLastAnnotation(out var runCommand) &&
c.Resource.TryGetLastAnnotation(out var packageManager))
{
if (!string.IsNullOrEmpty(packageManager.ScriptCommand))
{
c.Args.Add(packageManager.ScriptCommand);
}
c.Args.Add(runCommand.ScriptName);
foreach (var arg in runCommand.Args)
{
c.Args.Add(arg);
}
}
else
{
c.Args.Add(scriptPath);
}
})
.WithIconName("CodeJsRectangle")
.PublishAsDockerFile(c =>
{
// Only generate a Dockerfile if one doesn't already exist in the app directory
if (File.Exists(Path.Combine(resource.WorkingDirectory, "Dockerfile")))
{
return;
}
c.WithDockerfileBuilder(resource.WorkingDirectory, dockerfileContext =>
{
var defaultBaseImage = new Lazy(() => GetDefaultBaseImage(appDirectory, "alpine", dockerfileContext.Services));
// Get custom base image from annotation, if present. A caller can configure only a runtime
// image, which leaves BuildImage null, so fall back to the package manager's own image
// before the Node.js default - bun and deno are absent from the Node.js images.
dockerfileContext.Resource.TryGetLastAnnotation(out var baseImageAnnotation);
resource.TryGetLastAnnotation(out var packageManager);
var baseBuildImage = baseImageAnnotation?.BuildImage
?? packageManager?.DefaultBuildImage
?? defaultBaseImage.Value;
var builderStage = dockerfileContext.Builder
.From(baseBuildImage, "build")
.EmptyLine()
.WorkDir("/app");
if (packageManager is not null)
{
// Initialize the Docker build stage with package manager-specific setup commands.
// This allows package managers to add prerequisite commands (e.g., enabling pnpm via corepack)
// before package installation and build steps.
packageManager.InitializeDockerBuildStage?.Invoke(builderStage);
var copiedAllSource = false;
if (resource.TryGetLastAnnotation(out var installCommand))
{
copiedAllSource = builderStage.CopyPackageFilesForInstall(packageManager);
builderStage.AddInstallCommand(packageManager, installCommand);
}
if (!copiedAllSource)
{
// Copy application source code after dependencies are installed
builderStage.Copy(".", ".");
}
if (resource.TryGetLastAnnotation(out var buildCommand))
{
builderStage.EmptyLine()
.Run(BuildPackageScriptCommand(packageManager, buildCommand));
}
}
else
{
// No package manager, just copy everything
builderStage.Copy(".", ".");
}
var logger = dockerfileContext.Services.GetService>();
dockerfileContext.Builder.AddContainerFilesStages(dockerfileContext.Resource, logger);
var baseRuntimeImage = baseImageAnnotation?.RuntimeImage ?? defaultBaseImage.Value;
var runtimeBuilder = dockerfileContext.Builder
.From(baseRuntimeImage, "runtime")
.EmptyLine()
.WorkDir("/app")
.CopyFrom("build", "/app", "/app")
.AddContainerFiles(dockerfileContext.Resource, "/app", logger)
.EmptyLine()
.Env("NODE_ENV", "production")
.EmptyLine()
.User("node")
.EmptyLine()
.Entrypoint([resource.Command, scriptPath]);
});
});
// Configure pipeline to ensure container file sources are built first
resourceBuilder.WithPipelineConfiguration(context =>
{
if (resourceBuilder.Resource.TryGetAnnotationsOfType(out var containerFilesAnnotations))
{
var buildSteps = context.GetSteps(resourceBuilder.Resource, WellKnownPipelineTags.BuildCompute);
foreach (var containerFile in containerFilesAnnotations)
{
buildSteps.DependsOn(context.GetSteps(containerFile.Source, WellKnownPipelineTags.BuildCompute));
}
}
});
if (File.Exists(Path.Combine(appDirectory, "package.json")))
{
// Automatically add npm as the package manager if a package.json file exists
resourceBuilder.WithNpm();
}
resourceBuilder.WithVSCodeDebugging(scriptPath, "node");
if (builder.ExecutionContext.IsRunMode)
{
builder.OnBeforeStart((_, _) =>
{
// set the command to the package manager executable if the JavaScriptRunScriptAnnotation is present
if (resourceBuilder.Resource.TryGetLastAnnotation(out _) &&
resourceBuilder.Resource.TryGetLastAnnotation(out var packageManager))
{
resourceBuilder.WithCommand(packageManager.ExecutableName);
}
return Task.CompletedTask;
});
}
return resourceBuilder;
}
private static IResourceBuilder WithNodeDefaults(this IResourceBuilder builder) where TResource : JavaScriptAppResource =>
builder.WithOtlpExporter()
.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 WithRequiredCommandsFromPackageManager(
this IResourceBuilder 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().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(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(out _);
var runCommand = launchesViaRunScript && packageManager is not null
? packageManager.ExecutableName
: runtimeCommand;
var commands = new HashSet(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,
"deno" => DenoHelpLink,
"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 /.dockerignore to override.
node_modules
.git
.gitignore
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.env
.env.*
.aspire
aspire-output
Dockerfile
Dockerfile.*
*.Dockerfile.dockerignore
.dockerignore
*.tsbuildinfo
""";
///
/// Adds a Bun application to the application model. Bun should be available on the PATH.
///
/// The to add the resource to.
/// The name of the resource.
/// The path to the directory containing the Bun application.
/// The path to the script (for example, server.ts) relative to to run.
/// A reference to the .
/// The resource builder.
///
/// This method executes the script directly using bun <script>. Bun natively runs JavaScript and TypeScript
/// files so no transpile step is required.
///
/// If the application directory contains a package.json file, Bun will be added as the default package manager.
/// When publishing to a container, the default base image is oven/bun:1 for both the build and runtime stages.
///
///
/// Add a Bun app to the application model:
///
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddBunApp("api", "../api", "server.ts");
///
/// builder.Build().Run();
///
///
[AspireExport]
public static IResourceBuilder AddBunApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string scriptPath)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(scriptPath);
appDirectory = Path.GetFullPath(appDirectory, builder.AppHostDirectory);
var resource = new BunAppResource(name, "bun", appDirectory);
var resourceBuilder = builder.AddResource(resource)
.WithBunDefaults()
.WithArgs(c =>
{
// If the JavaScriptRunScriptAnnotation is present, use that to run the app
if (c.Resource.TryGetLastAnnotation(out var runCommand) &&
c.Resource.TryGetLastAnnotation(out var packageManager))
{
if (!string.IsNullOrEmpty(packageManager.ScriptCommand))
{
c.Args.Add(packageManager.ScriptCommand);
}
c.Args.Add(runCommand.ScriptName);
foreach (var arg in runCommand.Args)
{
c.Args.Add(arg);
}
}
else
{
c.Args.Add(scriptPath);
}
})
.WithIconName("CodeJsRectangle")
.PublishAsDockerFile(c =>
{
// Only generate a Dockerfile if one doesn't already exist in the app directory
if (File.Exists(Path.Combine(resource.WorkingDirectory, "Dockerfile")))
{
return;
}
c.WithDockerfileBuilder(resource.WorkingDirectory, dockerfileContext =>
{
// Get custom base image from annotation, if present
dockerfileContext.Resource.TryGetLastAnnotation(out var baseImageAnnotation);
// Provide a default .dockerignore that publishers emit alongside the generated
// Dockerfile using BuildKit's per-Dockerfile ignore convention
// (.dockerignore). The runtime stage below copies source
// directly from the build context (`COPY . .`), so without an ignore file the
// user's local node_modules, .git, etc. would leak into the build context and
// into the image. Matches the recommendation at
// https://bun.com/guides/ecosystem/docker. The annotation lookup is guarded
// because WithDockerfileBuilder always adds a DockerfileBuildAnnotation, but
// we want to remain robust if a future refactor changes that.
if (dockerfileContext.Resource.TryGetLastAnnotation(out var dockerfileBuildAnnotation))
{
dockerfileBuildAnnotation.BuildContextIgnoreContent ??= DefaultBunBuildContextIgnoreContent;
}
// Bun ships its own runtime, so both stages default to the same Bun image rather than
// using a node-based image as in AddNodeApp.
var baseBuildImage = baseImageAnnotation?.BuildImage ?? DefaultBunImage;
var builderStage = dockerfileContext.Builder
.From(baseBuildImage, "build")
.EmptyLine()
.WorkDir("/app");
if (resource.TryGetLastAnnotation(out var packageManager))
{
// Initialize the Docker build stage with package manager-specific setup commands.
packageManager.InitializeDockerBuildStage?.Invoke(builderStage);
var copiedAllSource = false;
if (resource.TryGetLastAnnotation(out var installCommand))
{
copiedAllSource = builderStage.CopyPackageFilesForInstall(packageManager);
builderStage.AddInstallCommand(packageManager, installCommand);
}
if (!copiedAllSource)
{
builderStage.Copy(".", ".");
}
if (resource.TryGetLastAnnotation(out var buildCommand))
{
builderStage.EmptyLine()
.Run(BuildPackageScriptCommand(packageManager, buildCommand));
}
}
else
{
// No package manager, just copy everything
builderStage.Copy(".", ".");
}
var logger = dockerfileContext.Services.GetService>();
dockerfileContext.Builder.AddContainerFilesStages(dockerfileContext.Resource, logger);
// When the package manager exposes production-only install args (e.g. bun's
// `--production`), emit a dedicated `prod-deps` stage that installs only the
// runtime dependencies. The runtime stage then overlays this stage's
// `node_modules` on top of the build output so the final image does not ship
// devDependencies. This mirrors the multi-stage pattern recommended at
// https://bun.com/guides/ecosystem/docker.
JavaScriptPackageManagerAnnotation? prodPackageManager = null;
JavaScriptInstallCommandAnnotation? prodInstallCommand = null;
var emitProdDepsStage =
resource.TryGetLastAnnotation(out prodPackageManager) &&
resource.TryGetLastAnnotation(out prodInstallCommand) &&
!string.IsNullOrEmpty(prodInstallCommand.ProductionInstallArgs);
if (emitProdDepsStage)
{
var pm = prodPackageManager!;
var install = prodInstallCommand!;
var prodDepsStage = dockerfileContext.Builder
.From(baseBuildImage, "prod-deps")
.EmptyLine()
.WorkDir("/app");
pm.InitializeDockerBuildStage?.Invoke(prodDepsStage);
if (pm.PackageFilesPatterns.Count > 0)
{
foreach (var packageFilePattern in pm.PackageFilesPatterns)
{
prodDepsStage.Copy(packageFilePattern.Source, packageFilePattern.Destination);
}
}
else
{
prodDepsStage.Copy("package.json", "./");
}
var prodInstallCmd = BuildProductionInstallCommand(pm, install);
if (!string.IsNullOrEmpty(pm.CacheMount))
{
prodDepsStage.Run($"--mount=type=cache,target={pm.CacheMount} {prodInstallCmd}");
}
else
{
prodDepsStage.Run(prodInstallCmd);
}
}
var baseRuntimeImage = baseImageAnnotation?.RuntimeImage ?? DefaultBunImage;
var runtimeBuilder = dockerfileContext.Builder
.From(baseRuntimeImage, "runtime")
.EmptyLine()
.WorkDir("/app");
if (emitProdDepsStage)
{
// Mirror the multi-stage pattern recommended at https://bun.com/guides/ecosystem/docker:
// pull node_modules from the production-only install stage and the rest of the app
// source from the build context. The build stage exists for validation/caching but
// its filesystem is intentionally not copied here, because Docker's COPY --from=
// merges directories and would let devDependencies survive the overlay.
//
// A matching .dockerignore is emitted next to the published Dockerfile via the
// DockerfileBuildAnnotation.BuildContextIgnoreContent property (BuildKit's
// .dockerignore convention) so local build artifacts
// (node_modules, .git, .aspire, etc.) do not leak into the image via COPY . . below.
runtimeBuilder
.CopyFrom("prod-deps", "/app/node_modules", "./node_modules")
.Copy(".", ".");
}
else
{
runtimeBuilder.CopyFrom("build", "/app", "/app");
}
runtimeBuilder
.AddContainerFiles(dockerfileContext.Resource, "/app", logger)
.EmptyLine()
.Env("NODE_ENV", "production")
.EmptyLine()
// The official oven/bun images provide a non-root `bun` user (UID 1000).
// See https://hub.docker.com/r/oven/bun
.User("bun")
.EmptyLine()
.Entrypoint([resource.Command, scriptPath]);
});
});
// Configure pipeline to ensure container file sources are built first
resourceBuilder.WithPipelineConfiguration(context =>
{
if (resourceBuilder.Resource.TryGetAnnotationsOfType(out var containerFilesAnnotations))
{
var buildSteps = context.GetSteps(resourceBuilder.Resource, WellKnownPipelineTags.BuildCompute);
foreach (var containerFile in containerFilesAnnotations)
{
buildSteps.DependsOn(context.GetSteps(containerFile.Source, WellKnownPipelineTags.BuildCompute));
}
}
});
if (File.Exists(Path.Combine(appDirectory, "package.json")))
{
// Automatically add bun as the package manager if a package.json file exists
resourceBuilder.WithBun();
}
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(out _) &&
resourceBuilder.Resource.TryGetLastAnnotation(out var packageManager))
{
resourceBuilder.WithCommand(packageManager.ExecutableName);
}
return Task.CompletedTask;
});
}
return resourceBuilder;
}
private static IResourceBuilder WithBunDefaults(this IResourceBuilder builder) where TResource : JavaScriptAppResource =>
builder.WithOtlpExporter()
.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;
});
// The default Docker image used for AddDenoApp build and runtime stages.
// Pin to a concrete tag because denoland/deno does not publish floating major tags.
// The official image provides a non-root `deno` user.
private const string DefaultDenoImage = "denoland/deno:2.9.0";
// Default .dockerignore content emitted alongside the generated Deno Dockerfile using
// BuildKit's per-Dockerfile ignore convention. The runtime stage copies source from the
// build stage, but an ignore file keeps local .git, dotenv files, and Aspire artifacts out
// of the build context. Deno can still materialize node_modules for npm compatibility
// (`--node-modules-dir=auto` or `manual`), so keep local dependency folders out of
// the build context just like the Bun/Node variants.
private const string DefaultDenoBuildContextIgnoreContent = """
# Generated by Aspire. Author /.dockerignore to override.
node_modules
.git
.gitignore
.DS_Store
.env
.env.*
.aspire
aspire-output
Dockerfile
Dockerfile.*
*.Dockerfile.dockerignore
.dockerignore
*.tsbuildinfo
""";
///
/// Adds a Deno application to the application model. Deno should be available on the PATH.
///
/// The to add the resource to.
/// The name of the resource.
/// The path to the directory containing the Deno application.
/// The path to the script (for example, main.ts) relative to to run.
/// A reference to the .
/// The resource builder.
///
/// In run mode this method executes the script directly using deno run -A <script>. Generated
/// containers use the more restrictive deno run --allow-net --allow-env <script> default. Deno
/// natively runs JavaScript and TypeScript files so no transpile step is required. Deno's built-in OpenTelemetry
/// integration is enabled via the OTEL_DENO environment variable, so traces, metrics, and logs flow to the
/// Aspire dashboard with no application-level SDK wiring.
///
/// The full Deno flag surface (granular permissions, --config/--import-map/--lock, unstable
/// features, --watch, inspector flags, script args, and the run/task/serve sub-command
/// modes) can be configured with the fluent WithDeno* methods (for example ,
/// , , ). Configuring any of
/// these fully replaces the default arg vector, so a Deno workload never has to fall back to AddExecutable.
///
/// If the application directory contains a package.json, deno.json, or deno.jsonc file, Deno will
/// be added as the default package manager. When publishing to a container, the default base image is
/// denoland/deno:2.9.0 for both the build and runtime stages.
///
///
/// Add a Deno app to the application model:
///
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddDenoApp("api", "../api", "main.ts");
///
/// builder.Build().Run();
///
///
[AspireExport]
[Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public static IResourceBuilder AddDenoApp(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);
ValidateDenoScriptPath(scriptPath);
var resource = new DenoAppResource(name, "deno", appDirectory);
var resourceBuilder = builder.AddResource(resource)
.WithDenoDefaults()
.WithArgs(c =>
{
// An explicit Deno command-line annotation (configured via the WithDeno* fluent flag methods)
// composes with WithRunScript. If no fluent mode method selected run/task/serve explicitly,
// a run script still launches through `deno task ` and the Deno flags that are valid for
// task launches are preserved.
if (c.Resource.TryGetLastAnnotation(out var denoCommandLine))
{
var serveEndpointArguments = denoCommandLine.Mode == DenoCommandMode.Serve
? GetDenoServeEndpointArguments(c.Resource, c.ExecutionContext.IsPublishMode)
: null;
c.Resource.TryGetLastAnnotation(out var runScript);
c.Resource.TryGetLastAnnotation(out var packageManager);
foreach (var arg in BuildDenoArgs(
denoCommandLine,
scriptPath,
serveEndpointArguments,
runScript: runScript,
packageManager: packageManager))
{
c.Args.Add(arg);
}
}
// If the JavaScriptRunScriptAnnotation is present, use that to run the app via `deno task `.
else if (c.Resource.TryGetLastAnnotation(out var runCommand) &&
c.Resource.TryGetLastAnnotation(out var packageManager))
{
if (!string.IsNullOrEmpty(packageManager.ScriptCommand))
{
c.Args.Add(packageManager.ScriptCommand);
}
c.Args.Add(runCommand.ScriptName);
foreach (var arg in runCommand.Args)
{
c.Args.Add(arg);
}
}
else
{
// Direct execution differs from Node/Bun: Deno requires the `run` subcommand and, unlike
// Node/Bun, runs under a deny-by-default permission model. Aspire injects configuration via
// environment variables (PORT, OTLP endpoints, cert paths) and the app reads them with
// Deno.env / opens sockets with Deno.serve, both of which throw NotCapable without an explicit
// grant. `-A` (allow-all) is used to keep the developer experience on par with Node/Bun, whose
// runtimes are permissive by default. Users who want least-privilege can opt out with
// WithDenoAllowAll(false) and add explicit permission flags via the WithDeno* methods.
c.Args.Add("run");
c.Args.Add("-A");
c.Args.Add(scriptPath);
}
})
.WithIconName("CodeJsRectangle")
.PublishAsDockerFile(c =>
{
// Only generate a Dockerfile if one doesn't already exist in the app directory
if (File.Exists(Path.Combine(resource.WorkingDirectory, "Dockerfile")))
{
return;
}
c.WithDockerfileBuilder(resource.WorkingDirectory, dockerfileContext =>
{
// Get custom base image from annotation, if present
dockerfileContext.Resource.TryGetLastAnnotation(out var baseImageAnnotation);
// Provide a default .dockerignore emitted alongside the generated Dockerfile using
// BuildKit's per-Dockerfile ignore convention (.dockerignore). The
// runtime stage copies source from the build stage, so an ignore file keeps the user's
// local .git, dotenv files, and Aspire output out of the image.
if (dockerfileContext.Resource.TryGetLastAnnotation(out var dockerfileBuildAnnotation))
{
dockerfileBuildAnnotation.BuildContextIgnoreContent ??= DefaultDenoBuildContextIgnoreContent;
}
ThrowIfUnsupportedDenoDockerfileOptions(dockerfileContext.Resource);
// Deno ships its own runtime, so both stages default to the same Deno image. Unlike the Bun
// variant there is no separate production-dependency install stage: Deno caches remote and
// npm dependencies under DENO_DIR. Direct run/serve entrypoints pre-populate that cache in
// the build stage and use --cached-only at runtime. Task entrypoints are opaque shell
// commands in deno.json, so Aspire cannot safely infer their module graph.
var baseBuildImage = baseImageAnnotation?.BuildImage ?? DefaultDenoImage;
var buildStage = dockerfileContext.Builder
.From(baseBuildImage, "build");
// Package-script builds install from the manifest layer before copying the remaining source.
// Direct run/serve builds copy the full module graph first, then cache it with the same
// resolution and lock flags used by the runtime entrypoint.
dockerfileContext.Resource.TryGetLastAnnotation(out var publishMode);
if (publishMode?.Mode == JavaScriptPublishMode.PackageScript)
{
if (!dockerfileContext.Resource.TryGetLastAnnotation(out var packageManager) ||
!dockerfileContext.Resource.TryGetLastAnnotation(out var installCommand))
{
throw new InvalidOperationException("PublishAsPackageScript requires a Deno package manager. Add a deno.json file or call WithDeno().");
}
buildStage.EmptyLine();
packageManager.InitializeDockerBuildStage?.Invoke(buildStage);
buildStage
.EmptyLine()
.WorkDir("/app");
var copiedAllSource = buildStage.CopyPackageFilesForInstall(packageManager);
buildStage.AddInstallCommand(packageManager, installCommand);
if (!copiedAllSource)
{
buildStage.Copy(".", ".");
}
}
else
{
var denoCacheCommand = BuildDenoCacheCommand(dockerfileContext.Resource, scriptPath, resource.WorkingDirectory);
buildStage
.EmptyLine()
// Pin DENO_DIR to a deterministic path so the runtime stage can copy the cache
// regardless of the base image's own default. The official denoland/deno image
// already uses /deno-dir, but a custom build image (WithDockerfileBaseImage) may not.
.Env("DENO_DIR", "/deno-dir")
.EmptyLine()
.WorkDir("/app")
.Copy(".", ".")
.EmptyLine()
.Run(denoCacheCommand);
}
if (dockerfileContext.Resource.TryGetLastAnnotation(out var buildCommand))
{
if (!dockerfileContext.Resource.TryGetLastAnnotation(out var packageManager))
{
throw new InvalidOperationException("WithBuildScript requires a Deno package manager. Add a deno.json file or call WithDeno().");
}
buildStage
.EmptyLine()
.Run(BuildPackageScriptCommand(packageManager, buildCommand));
}
var logger = dockerfileContext.Services.GetService>();
dockerfileContext.Builder.AddContainerFilesStages(dockerfileContext.Resource, logger);
var hasCustomRuntimeImage = baseImageAnnotation?.RuntimeImage is not null;
var baseRuntimeImage = baseImageAnnotation?.RuntimeImage ?? DefaultDenoImage;
var runtimeStage = dockerfileContext.Builder
.From(baseRuntimeImage, "runtime")
.EmptyLine()
// Match the build stage's DENO_DIR so the copied cache is discovered at runtime.
.Env("DENO_DIR", "/deno-dir")
.EmptyLine()
.WorkDir("/app");
if (hasCustomRuntimeImage)
{
runtimeStage
.CopyFrom("build", "/app", "/app")
// Ship the pre-populated dependency cache so direct run/serve container starts
// resolve everything locally instead of re-fetching from the network.
.CopyFrom("build", "/deno-dir", "/deno-dir");
}
else
{
runtimeStage
.CopyFrom("build", "/app", "/app", DenoDefaultUserAndGroup)
// Ship the pre-populated dependency cache so direct run/serve container starts
// resolve everything locally instead of re-fetching from the network.
.CopyFrom("build", "/deno-dir", "/deno-dir", DenoDefaultUserAndGroup);
}
runtimeStage
.AddContainerFiles(dockerfileContext.Resource, "/app", logger)
.EmptyLine()
// Deno honors NODE_ENV in its Node-compatibility mode (npm: specifiers, package.json
// "exports" conditions) exactly as Node/Bun do. Match the Bun publish block.
.Env("NODE_ENV", "production")
.EmptyLine();
if (!hasCustomRuntimeImage)
{
// The default denoland/deno images provide a non-root `deno` user. Respect custom runtime
// images' configured defaults because not every supported Deno variant defines that user
// (for example, denoland/deno:2.1-distroless).
// See https://github.com/denoland/deno_docker
runtimeStage
.User(DenoDefaultUser)
.EmptyLine();
}
runtimeStage.Entrypoint(BuildDenoEntrypoint(dockerfileContext.Resource, resource.Command, scriptPath));
});
});
// Configure pipeline to ensure container file sources are built first
resourceBuilder.WithPipelineConfiguration(context =>
{
if (resourceBuilder.Resource.TryGetAnnotationsOfType(out var containerFilesAnnotations))
{
var buildSteps = context.GetSteps(resourceBuilder.Resource, WellKnownPipelineTags.BuildCompute);
foreach (var containerFile in containerFilesAnnotations)
{
buildSteps.DependsOn(context.GetSteps(containerFile.Source, WellKnownPipelineTags.BuildCompute));
}
}
});
// Automatically add Deno as the package manager if a Deno or npm project manifest exists. Deno projects are
// commonly configured through deno.json/deno.jsonc (tasks, imports), and Deno also honors package.json in its
// Node compatibility mode.
if (File.Exists(Path.Combine(appDirectory, "package.json")) ||
File.Exists(Path.Combine(appDirectory, "deno.json")) ||
File.Exists(Path.Combine(appDirectory, "deno.jsonc")))
{
resourceBuilder.WithDeno();
}
resourceBuilder.WithVSCodeDebugging(scriptPath, "deno");
if (builder.ExecutionContext.IsRunMode)
{
builder.OnBeforeStart((_, _) =>
{
ThrowIfDenoOptionsConflictWithPackageManager(resourceBuilder.Resource);
// Set the command to the package manager executable if a WithRunScript was configured.
// For the default Deno package manager this is a no-op (executable is "deno"), but it keeps the
// command consistent if a user opts into a different package manager.
if (resourceBuilder.Resource.TryGetLastAnnotation(out _) &&
resourceBuilder.Resource.TryGetLastAnnotation(out var packageManager))
{
resourceBuilder.WithCommand(packageManager.ExecutableName);
}
return Task.CompletedTask;
});
}
return resourceBuilder;
}
private static IResourceBuilder WithDenoDefaults(this IResourceBuilder builder) where TResource : JavaScriptAppResource
{
// Deno has first-class, built-in OpenTelemetry support. Setting OTEL_DENO=true enables automatic export
// of traces, metrics, and logs with no application-level SDK required. Enable it only when an OTLP HTTP
// endpoint is injected; dashboard-free AppHosts remain valid and should not require an observability
// backend merely because they host a Deno workload.
//
// Deno's native exporter sends OTLP as Protobuf over HTTP, so request that dashboard endpoint instead
// of Aspire's default gRPC preference.
//
// No `--unstable-otel` flag is emitted: native OTel is STABLE on the pinned Deno 2.9.0 image.
// Verified empirically on Deno 2.9.0 (2026-07) — `OTEL_DENO=true`
// alone activates and exports traces/metrics/logs; `--unstable-otel` is no longer listed by
// `deno run --help=unstable` and is only a backward-compat no-op. OTEL_DENO accepts only the literal
// "true"/"false" (not "1"), which is what we emit.
// See https://docs.deno.com/runtime/fundamentals/open_telemetry/
builder.WithDenoOtlpExporter()
.WithRequiredCommandsFromPackageManager("deno")
// Deno honors NODE_ENV in its Node-compatibility mode (npm: specifier resolution, package.json
// "exports" conditions) the same way Node/Bun do. Mirror the Bun defaults so npm-compat behaves.
// See https://docs.deno.com/runtime/reference/env_variables/
.WithEnvironment("NODE_ENV", builder.ApplicationBuilder.Environment.IsDevelopment() ? "development" : "production")
.WithCertificateTrustConfiguration((ctx) =>
{
if (ctx.Scope is CertificateTrustScope.Append or CertificateTrustScope.Override or CertificateTrustScope.System)
{
// DENO_CERT loads the configured PEM certificate file into Deno's trust store. The optional
// DENO_TLS_CA_STORE value below chooses whether that bundle is combined with Deno's Mozilla
// store, replaces it, or is combined with the operating system store.
// See https://docs.deno.com/runtime/reference/env_variables/#special-environment-variables
ctx.EnvironmentVariables["DENO_CERT"] = ctx.CertificateBundlePath;
// Deno's built-in OTLP exporter is implemented in Rust and uses the OpenTelemetry certificate
// variable rather than DENO_CERT for HTTPS exporter trust.
// See https://opentelemetry.io/docs/specs/otel/protocol/exporter/
ctx.EnvironmentVariables["OTEL_EXPORTER_OTLP_CERTIFICATE"] = ctx.CertificateBundlePath;
if (ctx.Scope == CertificateTrustScope.Override)
{
ctx.EnvironmentVariables["DENO_TLS_CA_STORE"] = "";
}
else if (ctx.Scope == CertificateTrustScope.System)
{
ctx.EnvironmentVariables["DENO_TLS_CA_STORE"] = "system";
}
}
return Task.CompletedTask;
});
return builder;
}
private static IResourceBuilder WithDenoOtlpExporter(this IResourceBuilder builder)
where TResource : IResourceWithEnvironment
{
builder.WithOtlpExporterIfEndpointAvailable(OtlpProtocol.HttpProtobuf);
var exporter = builder.Resource.Annotations.OfType().Last();
builder.Resource.Annotations.Remove(exporter);
builder.Resource.Annotations.Add(new DenoOtlpExporterAnnotation
{
RequiredProtocol = exporter.RequiredProtocol,
});
return builder;
}
private static void ValidateDenoScriptPath(string scriptPath)
{
if (!TryNormalizeDenoContainerRelativePath(scriptPath, out _))
{
throw new ArgumentException("The script path must resolve inside the Deno application directory.", nameof(scriptPath));
}
}
private static bool IsWindowsDriveQualifiedPath(string path) =>
path.Length >= 2 &&
char.IsAsciiLetter(path[0]) &&
path[1] == ':';
///
/// Adds a JavaScript application resource to the distributed application using the specified app directory and
/// run script.
///
/// The distributed application builder to which the JavaScript application resource will be added.
/// The unique name of the JavaScript application resource. Cannot be null or empty.
/// The path to the directory containing the JavaScript application.
/// The name of the npm script to run when starting the application. Defaults to "dev". Cannot be null or empty.
/// A resource builder for the newly added JavaScript application resource.
///
/// If a Dockerfile does not exist in the application's directory, one will be generated
/// automatically when publishing. The method configures the resource with Node.js defaults and sets up npm
/// integration.
///
[AspireExport]
public static IResourceBuilder AddJavaScriptApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string runScriptName = DefaultJavaScriptRunScriptName)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(appDirectory);
ArgumentException.ThrowIfNullOrEmpty(runScriptName);
appDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, appDirectory));
var resource = new JavaScriptAppResource(name, "npm", appDirectory);
return builder.CreateDefaultJavaScriptAppBuilder(resource, appDirectory, runScriptName);
}
///
/// Configures the JavaScript application to publish as a standalone static website served by YARP.
///
/// The JavaScript resource type.
/// The JavaScript resource builder.
/// Optional callback to configure .
/// The updated resource builder.
///
/// Thrown during generated Dockerfile creation when this method is used with a Deno app added by
/// AddDenoApp. Use AddJavaScriptApp(...).WithDeno() or provide a custom Dockerfile.
///
///
///
/// The published container uses a YARP reverse proxy image for static file serving.
/// To add an API reverse-proxy, use the overload that accepts an apiPath and apiTarget.
///
///
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExportIgnore(Reason = "Use the polyglot-compatible overload instead.")]
public static IResourceBuilder PublishAsStaticWebsite(
this IResourceBuilder builder,
Action? configure = null)
where TResource : JavaScriptAppResource
{
var options = new PublishAsStaticWebsiteOptions();
configure?.Invoke(options);
return PublishAsStaticWebsiteCore(builder, null, null, options);
}
///
/// Configures the JavaScript application to publish as a standalone static website served by YARP,
/// with an API reverse-proxy to the specified resource.
///
/// The JavaScript resource type.
/// The JavaScript resource builder.
///
/// A path prefix to reverse-proxy to a backend API. For example, /api proxies all requests
/// matching /api/{"{**catch-all}"} to the backend resource.
///
///
/// The backend resource to proxy API requests to. YARP uses service discovery to resolve the
/// appropriate endpoint, preferring HTTPS when available.
///
/// Optional callback to configure .
/// The updated resource builder.
///
/// Thrown during generated Dockerfile creation when this method is used with a Deno app added by
/// AddDenoApp. Use AddJavaScriptApp(...).WithDeno() or provide a custom Dockerfile.
///
///
///
/// The published container uses a YARP reverse proxy image for static file serving and API
/// reverse-proxy. YARP natively supports HTTPS backends and service discovery, so API proxy requests
/// work correctly across all deployment targets (Docker Compose, Azure App Service, etc.).
///
///
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExportIgnore(Reason = "Use the polyglot-compatible overload instead.")]
public static IResourceBuilder PublishAsStaticWebsite(
this IResourceBuilder builder,
string apiPath,
IResourceBuilder apiTarget,
Action? configure = null)
where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(apiTarget);
var options = new PublishAsStaticWebsiteOptions();
configure?.Invoke(options);
return PublishAsStaticWebsiteCore(builder, apiPath, apiTarget, options);
}
#pragma warning disable ASPIREEXPORT009 // Polyglot entry point — collision is intentional
///
/// Publishes the JavaScript application as a standalone static website using YARP.
///
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport("publishAsStaticWebsite")]
internal static IResourceBuilder PublishAsStaticWebsitePolyglot(
#pragma warning restore ASPIREEXPORT009
this IResourceBuilder builder,
string? apiPath = null,
IResourceBuilder? apiTarget = null,
string outputPath = "dist",
bool stripPrefix = false,
string? targetEndpointName = null)
where TResource : JavaScriptAppResource
{
var options = new PublishAsStaticWebsiteOptions
{
OutputPath = outputPath,
StripPrefix = stripPrefix,
TargetEndpointName = targetEndpointName
};
return PublishAsStaticWebsiteCore(builder, apiPath, apiTarget, options);
}
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
private static IResourceBuilder PublishAsStaticWebsiteCore(
IResourceBuilder builder,
string? apiPath,
IResourceBuilder? apiTarget,
PublishAsStaticWebsiteOptions options)
where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(options.OutputPath);
if (apiPath is not null && apiTarget is null)
{
throw new ArgumentException("apiTarget is required when apiPath is specified.", nameof(apiTarget));
}
if (apiTarget is not null && apiPath is null)
{
throw new ArgumentException("apiPath is required when apiTarget is specified.", nameof(apiPath));
}
if (apiPath is not null && apiTarget is not null)
{
if (!apiPath.StartsWith('/'))
{
throw new ArgumentException("The apiPath must start with '/'.", nameof(apiPath));
}
apiPath = apiPath.TrimEnd('/');
if (apiPath.Length == 0)
{
throw new ArgumentException("The apiPath must not be '/' — it would match all requests and make the static site unreachable.", nameof(apiPath));
}
ValidateApiPath(apiPath);
builder.WithReference(apiTarget);
}
if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
return builder;
}
// YARP listens on port 5000 by default in the base image, so configure an endpoint for that port
// and set ASPNETCORE_URLS to ensure Kestrel listens on the correct port as well for static file serving and API reverse-proxy to work correctly.
builder.WithEndpoint("http", e => e.TargetPort = 5000, createIfNotExists: true);
var annotation = new JavaScriptPublishModeAnnotation(JavaScriptPublishMode.StaticWebsite)
{
OutputPath = options.OutputPath,
};
builder.WithEnvironment(ctx =>
{
ctx.EnvironmentVariables["YARP_ENABLE_STATIC_FILES"] = "true";
if (apiPath is not null && apiTarget is not null)
{
// Resolve the destination address — use a specific endpoint if configured, otherwise service discovery
var destinationAddress = options.TargetEndpointName is not null
? apiTarget.Resource.GetEndpoint(options.TargetEndpointName)
: (object)BuildServiceDiscoveryUrl(apiTarget.Resource);
ctx.EnvironmentVariables["REVERSEPROXY__ROUTES__api__CLUSTERID"] = "api";
ctx.EnvironmentVariables["REVERSEPROXY__ROUTES__api__MATCH__PATH"] = $"{apiPath}/{{**catch-all}}";
ctx.EnvironmentVariables["REVERSEPROXY__CLUSTERS__api__DESTINATIONS__destination1__ADDRESS"] = destinationAddress;
if (options.StripPrefix)
{
ctx.EnvironmentVariables["REVERSEPROXY__ROUTES__api__TRANSFORMS__0__PATHREMOVEPREFIX"] = apiPath;
}
}
});
builder.WithAnnotation(annotation)
.ClearContainerFilesSources()
.WithContainerFilesSource(GetContainerFilesSourcePath(options.OutputPath))
.WithOtlpExporterIfMissing();
if (builder.Resource.TryGetLastAnnotation(out var dockerfileBuildAnnotation))
{
dockerfileBuildAnnotation.HasEntrypoint = true;
}
return builder;
}
///
/// Configures the JavaScript application to publish as a standalone Node.js server that runs a built artifact directly.
///
/// The JavaScript resource type.
/// The JavaScript resource builder.
///
/// The relative path to the Node.js entry point to execute in the published container after the build completes,
/// such as .output/server/index.mjs or build/index.js.
///
///
/// The relative path containing the built runtime files to copy into the published container. Defaults to the application root.
///
/// The updated resource builder.
///
/// Thrown during generated Dockerfile creation when this method is used with a Deno app added by
/// AddDenoApp. Use AddJavaScriptApp(...).WithDeno() or provide a custom Dockerfile.
///
///
///
/// Use this method for frameworks that produce a Node.js server artifact during the build and recommend
/// running that artifact directly in production rather than invoking a package manager script at runtime.
/// The application source is still built using the configured package manager and build script; this method
/// only changes the publish-time runtime container shape.
///
///
/// The container files source path is automatically set to so that only
/// the built output directory is copied into the runtime container, not the full application source.
///
///
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public static IResourceBuilder PublishAsNodeServer(this IResourceBuilder builder, string entryPoint, string outputPath = ".")
where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(entryPoint);
ArgumentException.ThrowIfNullOrEmpty(outputPath);
if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
return builder;
}
var annotation = new JavaScriptPublishModeAnnotation(JavaScriptPublishMode.NodeServer)
{
EntryPoint = entryPoint,
OutputPath = outputPath
};
builder.WithAnnotation(annotation)
.ClearContainerFilesSources()
.WithContainerFilesSource(GetContainerFilesSourcePath(outputPath))
.WithOtlpExporterIfMissing()
.WithEnvironment("HOST", "0.0.0.0")
.WithEnvironment("HOSTNAME", "0.0.0.0");
if (builder.Resource.TryGetLastAnnotation(out var dockerfileBuildAnnotation))
{
dockerfileBuildAnnotation.HasEntrypoint = true;
}
return builder;
}
///
/// Configures the JavaScript application to publish as a server that runs a package manager script at runtime.
///
/// The JavaScript resource type.
/// The JavaScript resource builder.
///
/// The name of the script to run in the published container. For Node.js and Bun applications this is a
/// package.json script; for Deno applications it is a task defined in deno.json.
/// For example, start invokes the configured package manager's run command for the start script,
/// such as npm run start, pnpm run start, yarn run start, bun run start, or
/// deno task start.
///
///
/// Optional arguments appended after the script name at runtime,
/// such as -- --port "$PORT".
///
/// The updated resource builder.
///
///
/// Use this method for frameworks where the production server depends on packages resolved at runtime, either
/// from node_modules or from the Deno dependency cache. The resulting container includes the full
/// application with its production dependencies already installed.
///
///
/// This method is appropriate for frameworks like Nuxt (where useAsyncData/useFetch requires the
/// full Nitro environment), Remix (where react-router-serve is an npm dependency), and Astro SSR
/// (where the built entry point imports unbundled @astrojs/* packages).
///
///
/// For Deno applications the generated container runs deno task <scriptName> and copies the populated
/// DENO_DIR cache from the build stage, so whatever the build resolved is already present at runtime.
/// Unlike the Node.js and Bun package managers there is no separate production install step, because the build
/// stage runs deno install and DENO_DIR is carried forward as-is rather than being pruned.
///
///
/// deno install only resolves the dependencies declared in deno.json or package.json. An
/// import written as a bare specifier in source, such as import { assert } from "jsr:@std/assert", is not
/// declared anywhere the installer can see, so it is fetched on first use and the container needs network access
/// at startup. The same applies to anything reachable only from inside the build script's own task command, which
/// Aspire cannot inspect. Add those imports to the imports map in deno.json if the container has to
/// start without network access.
///
///
/// For frameworks that produce a self-contained server artifact that does not require node_modules,
/// use instead for a smaller runtime image.
///
///
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public static IResourceBuilder PublishAsPackageScript(this IResourceBuilder builder, string scriptName = "start", string? runScriptArguments = null)
where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(scriptName);
if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
return builder;
}
var annotation = new JavaScriptPublishModeAnnotation(JavaScriptPublishMode.PackageScript)
{
ScriptName = scriptName,
RunScriptArguments = runScriptArguments
};
builder.WithAnnotation(annotation)
.ClearContainerFilesSources()
.WithOtlpExporterIfMissing()
.WithEnvironment("HOST", "0.0.0.0")
.WithEnvironment("HOSTNAME", "0.0.0.0");
if (builder.Resource.TryGetLastAnnotation(out var dockerfileBuildAnnotation))
{
dockerfileBuildAnnotation.HasEntrypoint = true;
}
return builder;
}
private static bool CopyPackageFilesForInstall(this DockerfileStage builderStage, JavaScriptPackageManagerAnnotation packageManager)
{
// deno.json can reference sibling import maps, workspace members, and other files that `deno install`
// resolves immediately. Copy the complete build context before install because the manifest files alone
// are not a self-contained dependency description.
if (packageManager.ExecutableName == "deno")
{
builderStage.Copy(".", ".");
return true;
}
if (packageManager.PackageFilesPatterns.Count > 0)
{
foreach (var packageFilePattern in packageManager.PackageFilesPatterns)
{
builderStage.Copy(packageFilePattern.Source, packageFilePattern.Destination);
}
return false;
}
builderStage.Copy(".", ".");
return true;
}
private static IResourceBuilder WithOtlpExporterIfMissing(this IResourceBuilder builder)
where TResource : JavaScriptAppResource
{
if (!builder.Resource.Annotations.OfType().Any())
{
builder.WithOtlpExporter();
}
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 = JoinDockerShellCommand([packageManager.ExecutableName, .. installCommand.Args]);
if (!string.IsNullOrEmpty(packageManager.CacheMount))
{
builderStage.Run($"--mount=type=cache,target={packageManager.CacheMount} {installCmd}");
}
else
{
builderStage.Run(installCmd);
}
}
///
/// Builds the RUN command that executes a package script during the Docker build, for example
/// npm run build or deno task build.
///
///
/// The script name and its arguments are caller-supplied and are each a single logical token, so they are
/// shell-quoted. A script named build prod would otherwise emit RUN npm run build prod, which
/// runs the build script with an extra argument instead of the script the caller named.
///
private static string BuildPackageScriptCommand(JavaScriptPackageManagerAnnotation packageManager, JavaScriptBuildScriptAnnotation buildCommand)
{
var commandArgs = new List() { packageManager.ExecutableName };
if (!string.IsNullOrEmpty(packageManager.ScriptCommand))
{
commandArgs.Add(packageManager.ScriptCommand);
}
commandArgs.Add(buildCommand.ScriptName);
commandArgs.AddRange(buildCommand.Args);
return JoinDockerShellCommand(commandArgs);
}
///
/// Builds the production dependency install command, appending the package manager's production-only flag.
///
///
/// is deliberately not quoted. Unlike the
/// entries in , which are individual tokens, it is documented
/// as a pre-formatted flag fragment (for example --omit=dev), so quoting it would break a caller who
/// supplies more than one flag.
///
private static string BuildProductionInstallCommand(JavaScriptPackageManagerAnnotation packageManager, JavaScriptInstallCommandAnnotation installCommand)
=> $"{JoinDockerShellCommand([packageManager.ExecutableName, .. installCommand.Args])} {installCommand.ProductionInstallArgs}";
///
/// Joins arguments into a single command string for Dockerfile RUN, quoting each argument so that
/// values containing spaces or shell metacharacters survive as one token.
///
private static string JoinDockerShellCommand(IEnumerable args)
=> string.Join(' ', args.Select(QuoteDockerShellArgument));
///
/// Quotes a single argument for a Dockerfile RUN instruction, which is executed through /bin/sh -c.
///
///
/// Uses a fail-closed allowlist: anything outside the set of characters that are unambiguously inert to the shell
/// is quoted. A denylist would silently pass through any metacharacter nobody enumerated.
///
private static string QuoteDockerShellArgument(string value)
{
if (value.Length == 0)
{
return "''";
}
if (value.All(IsUnquotedDockerShellArgumentCharacter))
{
return value;
}
// Single-quote the argument and use the standard POSIX shell escape sequence for embedded quotes:
// import map's.json -> 'import map'"'"'s.json'
return $"'{value.Replace("'", "'\"'\"'", StringComparison.Ordinal)}'";
}
private static bool IsUnquotedDockerShellArgumentCharacter(char c) =>
c is >= 'a' and <= 'z'
or >= 'A' and <= 'Z'
or >= '0' and <= '9'
or '-'
or '_'
or '.'
or '/'
or ':'
or '=';
private static string GetPackageScriptRuntimeImage(
string appDirectory,
IServiceProvider services,
DockerfileBaseImageAnnotation? baseImageAnnotation,
JavaScriptPackageManagerAnnotation packageManager,
string buildImage)
{
if (!string.IsNullOrEmpty(baseImageAnnotation?.RuntimeImage))
{
return baseImageAnnotation.RuntimeImage;
}
return packageManager.ResolvePackageScriptRuntimeImage?.Invoke(buildImage)
?? GetDefaultBaseImage(appDirectory, "alpine", services);
}
private static IResourceBuilder CreateDefaultJavaScriptAppBuilder(
this IDistributedApplicationBuilder builder,
TResource resource,
string appDirectory,
string runScriptName,
Action? argsCallback = null) where TResource : JavaScriptAppResource
{
var resourceBuilder = builder.AddResource(resource)
.WithNodeDefaults()
.WithArgs(c =>
{
if (c.Resource.TryGetLastAnnotation(out var runCommand))
{
if (c.Resource.TryGetLastAnnotation(out var packageManager) &&
!string.IsNullOrEmpty(packageManager.ScriptCommand))
{
c.Args.Add(packageManager.ScriptCommand);
}
c.Args.Add(runCommand.ScriptName);
foreach (var arg in runCommand.Args)
{
c.Args.Add(arg);
}
}
argsCallback?.Invoke(c);
})
.WithIconName("CodeJsRectangle")
.WithNpm()
.PublishAsDockerFile(c =>
{
// Only generate a Dockerfile if one doesn't already exist in the app directory
if (File.Exists(Path.Combine(appDirectory, "Dockerfile")))
{
return;
}
c.WithDockerfileBuilder(appDirectory, dockerfileContext =>
{
dockerfileContext.Resource.TryGetLastAnnotation(out var publishMode);
if (c.Resource.TryGetLastAnnotation(out var packageManager))
{
// Get custom base image from annotation, if present. A caller can configure only a runtime
// image, which leaves BuildImage null, so fall back to the package manager's own image
// before the Node.js default - bun and deno are absent from the Node.js images.
dockerfileContext.Resource.TryGetLastAnnotation(out var baseImageAnnotation);
var baseImage = baseImageAnnotation?.BuildImage
?? packageManager.DefaultBuildImage
?? 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 = dockerBuilder.CopyPackageFilesForInstall(packageManager);
if (c.Resource.TryGetLastAnnotation(out var installCommand))
{
dockerBuilder.AddInstallCommand(packageManager, installCommand);
}
if (!copiedAllSource)
{
// Copy application source code after dependencies are installed
dockerBuilder.Copy(".", ".");
}
if (c.Resource.TryGetLastAnnotation(out var buildCommand))
{
dockerBuilder.Run(BuildPackageScriptCommand(packageManager, buildCommand));
}
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);
var runCommand = string.IsNullOrWhiteSpace(publishMode.RunScriptArguments)
? $"{packageManager.ExecutableName} {packageManager.ScriptCommand ?? "run"} {publishMode.ScriptName}"
: $"{packageManager.ExecutableName} {packageManager.ScriptCommand ?? "run"} {publishMode.ScriptName} {publishMode.RunScriptArguments}";
if (packageManager.ExecutableName == "deno")
{
var usesDefaultDenoRuntimeImage = string.Equals(runtimeImage, DefaultDenoImage, StringComparison.Ordinal);
var denoRuntimeStage = dockerfileContext.Builder
.From(runtimeImage, "runtime")
.WorkDir("/app");
if (usesDefaultDenoRuntimeImage)
{
denoRuntimeStage.CopyFrom("build", "/app", "/app", DenoDefaultUserAndGroup);
}
else
{
denoRuntimeStage.CopyFrom("build", "/app", "/app");
}
// Carry the populated dependency store across stages so the container does not
// re-download dependencies on first run.
denoRuntimeStage.Env("DENO_DIR", DenoCacheDirectory);
if (usesDefaultDenoRuntimeImage)
{
denoRuntimeStage.CopyFrom("build", DenoCacheDirectory, DenoCacheDirectory, DenoDefaultUserAndGroup);
}
else
{
denoRuntimeStage.CopyFrom("build", DenoCacheDirectory, DenoCacheDirectory);
}
packageManager.InitializeDockerRuntimeStage?.Invoke(denoRuntimeStage);
denoRuntimeStage
.Env("NODE_ENV", "production");
if (usesDefaultDenoRuntimeImage)
{
denoRuntimeStage.User(DenoDefaultUser);
}
// Exec form (no `sh -c`) so the container also works with shell-less Deno
// runtime images such as denoland/deno:*-distroless.
denoRuntimeStage.Entrypoint(BuildDenoPackageScriptEntrypoint(
packageManager.ExecutableName,
packageManager.ScriptCommand ?? "run",
publishMode.ScriptName!,
publishMode.RunScriptArguments));
break;
}
// Production dependencies stage for optimized image
var prodDepsStage = dockerfileContext.Builder
.From(baseImage, "prod-deps")
.WorkDir("/app");
packageManager.InitializeDockerBuildStage?.Invoke(prodDepsStage);
if (packageManager.PackageFilesPatterns.Count > 0)
{
foreach (var packageFilePattern in packageManager.PackageFilesPatterns)
{
prodDepsStage.Copy(packageFilePattern.Source, packageFilePattern.Destination);
}
}
else
{
prodDepsStage.Copy("package*.json", "./");
}
// Install production-only dependencies using the same base install
// command as the build stage (e.g. 'ci' for npm, 'install --frozen-lockfile'
// for pnpm) plus the production-only flag (e.g. '--omit=dev').
var installAnnotation = c.Resource.TryGetLastAnnotation(out var installCmd) ? installCmd : null;
if (string.IsNullOrEmpty(installAnnotation?.ProductionInstallArgs))
{
throw new InvalidOperationException($"Package manager '{packageManager.ExecutableName}' does not have ProductionInstallArgs configured, which is required for PublishAsPackageScript.");
}
var prodInstallCmd = BuildProductionInstallCommand(packageManager, installAnnotation);
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 runtimeStage = dockerfileContext.Builder
.From(runtimeImage, "runtime")
.WorkDir("/app")
.CopyFrom("build", "/app", "/app")
.CopyFrom("prod-deps", "/app/node_modules", "./node_modules");
packageManager.InitializeDockerRuntimeStage?.Invoke(runtimeStage);
runtimeStage
.Env("NODE_ENV", "production")
.Entrypoint(["sh", "-c", $"exec {runCommand}"]);
break;
}
case JavaScriptPublishMode.NextStandalone:
{
var runtimeImage = baseImageAnnotation?.RuntimeImage ?? GetDefaultBaseImage(appDirectory, "alpine", dockerfileContext.Services);
// Match the ownership pattern from the official Next.js sample:
// https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
dockerfileContext.Builder
.From(runtimeImage, "runtime")
.WorkDir("/app")
.Env("NODE_ENV", "production")
.CopyFrom("build", "/app/public", "./public", "node:node")
.Run("mkdir .next")
.Run("chown node:node .next")
.CopyFrom("build", "/app/.next/standalone", "./", "node:node")
.CopyFrom("build", "/app/.next/static", "./.next/static", "node:node")
.User("node")
.Entrypoint(["node", "server.js"]);
break;
}
}
}
});
// JavaScript apps default to build-only publishing unless a standalone runtime is enabled.
if (resource.TryGetLastAnnotation(out var dockerFileAnnotation))
{
dockerFileAnnotation.HasEntrypoint =
resource.TryGetLastAnnotation(out _);
}
else
{
throw new InvalidOperationException("DockerfileBuildAnnotation should exist after calling PublishAsDockerFile.");
}
})
.WithAnnotation(new ContainerFilesSourceAnnotation() { SourcePath = "/app/dist" })
.WithBuildScript("build")
.WithRunScript(runScriptName);
if (builder.ExecutionContext.IsPublishMode &&
builder.TryCreateResourceBuilder(resource.Name, out var containerBuilder))
{
var validationStepName = $"validate-javascript-dockerfile-run-script-{resource.Name}";
Task WriteValidatedContainerAsync(ManifestPublishingContext context)
{
ValidateExistingDockerfileRunScript(resource, containerBuilder.Resource);
return context.WriteContainerAsync(containerBuilder.Resource);
}
resourceBuilder.WithManifestPublishingCallback(WriteValidatedContainerAsync);
containerBuilder.WithManifestPublishingCallback(WriteValidatedContainerAsync);
containerBuilder.WithAnnotation(new PipelineStepAnnotation(_ => new PipelineStep
{
Name = validationStepName,
Description = $"Validates that JavaScript app '{resource.Name}' does not publish an ignored run script with an existing Dockerfile.",
RequiredBySteps = [WellKnownPipelineSteps.Build, WellKnownPipelineSteps.Publish],
Resource = containerBuilder.Resource,
Action = _ =>
{
ValidateExistingDockerfileRunScript(resource, containerBuilder.Resource);
return Task.CompletedTask;
}
}));
}
resourceBuilder.WithVSCodeDebugging();
// ensure the package manager command is set before starting the resource
if (builder.ExecutionContext.IsRunMode)
{
builder.OnBeforeStart((_, _) =>
{
if (resourceBuilder.Resource.TryGetLastAnnotation(out var packageManager))
{
resourceBuilder.WithCommand(packageManager.ExecutableName);
}
return Task.CompletedTask;
});
}
return resourceBuilder;
}
private static void ValidateExistingDockerfileRunScript(JavaScriptAppResource resource, ContainerResource containerResource)
{
if (containerResource.Entrypoint is not null ||
!containerResource.TryGetLastAnnotation(out var dockerfileBuildAnnotation) ||
dockerfileBuildAnnotation.DockerfileFactory is not null ||
!containerResource.TryGetLastAnnotation(out var runScript))
{
return;
}
// The user's effective run-script intent is captured by the last annotation: AddJavaScriptApp
// always adds one with the supplied runScriptName, and any subsequent WithRunScript call
// appends another. Comparing the last annotation against the default avoids false positives
// when the user re-states the default explicitly (e.g. .WithRunScript("dev")).
var hasExplicitRunScript =
!string.Equals(runScript.ScriptName, DefaultJavaScriptRunScriptName, StringComparison.Ordinal) ||
runScript.Args is { Length: > 0 };
if (!hasExplicitRunScript)
{
return;
}
// Include the args in the message when they are the trigger, so the user can see why
// a default-named script (e.g. "dev") still produced a conflict.
var argsClause = runScript.Args is { Length: > 0 }
? $" with args [{string.Join(", ", runScript.Args)}]"
: string.Empty;
// Existing Dockerfiles are user-authored, so Aspire cannot safely assume that replacing
// their entrypoint with a package-manager script will work for the image shape.
// If the user provides an explicit container entrypoint above, honor it; otherwise fail
// instead of silently publishing an image that ignores the requested run script.
throw new DistributedApplicationException(
$"JavaScript app resource '{resource.Name}' is configured to run script '{runScript.ScriptName}'{argsClause}, but publish is using the existing Dockerfile '{dockerfileBuildAnnotation.DockerfilePath}'. " +
"An existing Dockerfile entrypoint cannot be changed automatically from runScriptName or WithRunScript. " +
"Remove or rename the Dockerfile so Aspire can generate one, or call PublishAsDockerFile(...) and set the container entrypoint explicitly.");
}
///
/// Adds a Vite app to the distributed application builder.
///
/// The to add the resource to.
/// The name of the Vite app.
/// The path to the directory containing the Vite app.
/// The name of the script that runs the Vite app. Defaults to "dev".
/// A reference to the .
/// The resource builder.
///
///
/// The following example creates a Vite app using npm as the package manager.
///
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddViteApp("frontend", "./frontend");
///
/// builder.Build().Run();
///
///
///
[AspireExport]
public static IResourceBuilder AddViteApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string runScriptName = "dev")
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(appDirectory);
appDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, appDirectory));
var 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(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();
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();
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;
}
///
/// Adds a Next.js app to the distributed application builder.
///
/// The to add the resource to.
/// The name of the Next.js app.
/// The path to the directory containing the Next.js app.
/// The name of the script that runs the Next.js dev server. Defaults to "dev".
/// A reference to the .
/// The resource builder.
///
///
/// This method configures the Next.js application for both local development and publishing.
/// In run mode, it starts the Next.js dev server with the correct port binding.
/// In publish mode, it generates a multi-stage Dockerfile using Next.js standalone output mode,
/// which copies public/, .next/standalone/, and .next/static/ into a
/// Node.js runtime container.
///
///
/// The Next.js application must have output: "standalone" configured in next.config.ts
/// and a public/ directory (even if empty) for the published container to build correctly.
///
///
/// The following example creates a Next.js app.
///
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddNextJsApp("frontend", "./frontend");
///
/// builder.Build().Run();
///
///
///
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public static IResourceBuilder AddNextJsApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string runScriptName = "dev")
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(appDirectory);
appDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, appDirectory));
var resource = new NextJsAppResource(name, "npm", appDirectory);
var resourceBuilder = builder.CreateDefaultJavaScriptAppBuilder(
resource,
appDirectory,
runScriptName,
argsCallback: c =>
{
if (c.Resource.TryGetLastAnnotation(out var packageManager) &&
packageManager.CommandSeparator is string separator)
{
c.Args.Add(separator);
}
var targetEndpoint = resource.GetEndpoint("https");
if (!targetEndpoint.Exists)
{
targetEndpoint = resource.GetEndpoint("http");
}
c.Args.Add("-p");
c.Args.Add(targetEndpoint.Property(EndpointProperty.TargetPort));
})
.WithHttpEndpoint(env: "PORT")
.WithOtlpExporter();
if (builder.ExecutionContext.IsPublishMode)
{
resourceBuilder
.WithAnnotation(new JavaScriptPublishModeAnnotation(JavaScriptPublishMode.NextStandalone))
.ClearContainerFilesSources()
.WithEnvironment("HOSTNAME", "0.0.0.0");
if (resourceBuilder.Resource.TryGetLastAnnotation(out var dockerfileBuildAnnotation))
{
dockerfileBuildAnnotation.HasEntrypoint = true;
}
}
// Add a publish prereq step that validates the Next.js config has standalone output enabled.
// This runs at deploy time (not resource creation time) so it doesn't block `aspire start`.
// Can be disabled with .DisableBuildValidation().
resourceBuilder.WithAnnotation(new PipelineStepAnnotation(factoryCtx =>
[
new PipelineStep
{
Name = $"nextjs-standalone-check-{name}",
Description = $"Validates that the Next.js app '{name}' has output: \"standalone\" configured.",
DependsOnSteps = [WellKnownPipelineSteps.BuildPrereq],
RequiredBySteps = [WellKnownPipelineSteps.Build],
Resource = resourceBuilder.Resource,
Action = _ =>
{
if (!resourceBuilder.Resource.TryGetLastAnnotation(out var suppress))
{
ValidateNextJsStandaloneOutput(appDirectory);
}
return Task.CompletedTask;
}
}
]));
return resourceBuilder;
}
///
/// Disables deploy-time build validation checks for the Next.js application.
///
/// The resource builder.
/// The resource builder for chaining.
///
/// By default, adds publish prerequisite steps that verify
/// the Next.js configuration (e.g. that output: "standalone" is set). Use this method
/// to suppress those checks when the configuration is set dynamically or via an external
/// mechanism that cannot be detected by static file inspection.
///
[Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public static IResourceBuilder DisableBuildValidation(this IResourceBuilder builder)
{
return builder.WithAnnotation(new());
}
///
/// Configures the Vite app to use the specified Vite configuration file instead of the default resolution behavior.
///
/// The resource builder.
/// The path to the Vite configuration file. Relative to the Vite service project root.
/// The resource builder.
///
/// Use this method to specify a specific Vite configuration file if you need to override the default Vite configuration resolution behavior.
///
///
/// Use a custom Vite configuration file:
///
/// var builder = DistributedApplication.CreateBuilder(args);
/// var viteApp = builder.AddViteApp("frontend", "./frontend")
/// .WithViteConfig("./vite.production.config.js");
///
///
[AspireExport]
public static IResourceBuilder WithViteConfig(this IResourceBuilder builder, string configPath)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(configPath);
builder.Resource.ViteConfigPath = configPath;
return builder;
}
///
/// Configures the Node.js resource to use npm as the package manager and optionally installs packages before the application starts.
///
/// The NodeAppResource.
/// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource.
/// The install command itself passed to npm to install dependencies.
/// The command-line arguments passed to npm to install dependencies.
/// A reference to the .
/// The resource builder.
[AspireExport]
public static IResourceBuilder WithNpm(this IResourceBuilder resource, bool install = true, string? installCommand = null, string[]? installArgs = null) where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(resource);
installCommand ??= GetDefaultNpmInstallCommand(resource);
resource
.WithAnnotation(new JavaScriptPackageManagerAnnotation("npm", runScriptCommand: "run", cacheMount: "/root/.npm")
{
PackageFilesPatterns = { new CopyFilePattern("package*.json", "./") },
})
.WithAnnotation(new JavaScriptInstallCommandAnnotation([installCommand, .. installArgs ?? []])
{
ProductionInstallArgs = "--omit=dev"
});
AddInstaller(resource, install);
return resource;
}
///
/// Configures the JavaScript resource to use Bun as the package manager and optionally installs packages before the application starts.
///
/// The JavaScript application resource builder.
/// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource.
/// Additional command-line arguments passed to "bun install". When null, defaults are applied based on publish mode and lockfile presence.
/// A reference to the .
/// The resource builder.
///
/// Bun forwards script arguments without requiring the -- command separator, so this method configures the resource to omit it.
/// When publishing and a bun lockfile (bun.lock or bun.lockb) is present, --frozen-lockfile is used by default.
/// Publishing to a container requires Bun to be present in the build image. This method configures a Bun build image when one is not already specified.
/// also uses the Bun image for the runtime stage unless a custom runtime image is configured.
/// To use a specific Bun version, configure a custom build image (for example, oven/bun:<tag>) using .
///
///
///
/// Run a Vite app using Bun as the package manager:
///
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddViteApp("frontend", "./frontend")
/// .WithBun()
/// .WithDockerfileBaseImage(buildImage: "oven/bun:latest"); // To use a specific Bun image
///
/// builder.Build().Run();
///
///
[AspireExport]
public static IResourceBuilder WithBun(this IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(resource);
var workingDirectory = resource.Resource.WorkingDirectory;
var hasBunLock = File.Exists(Path.Combine(workingDirectory, "bun.lock")) ||
File.Exists(Path.Combine(workingDirectory, "bun.lockb"));
installArgs ??= GetDefaultBunInstallArgs(resource, hasBunLock);
var packageFilesSourcePattern = "package.json";
if (File.Exists(Path.Combine(workingDirectory, "bun.lock")))
{
packageFilesSourcePattern += " bun.lock";
}
if (File.Exists(Path.Combine(workingDirectory, "bun.lockb")))
{
packageFilesSourcePattern += " bun.lockb";
}
resource
.WithAnnotation(new JavaScriptPackageManagerAnnotation("bun", runScriptCommand: "run", cacheMount: "/root/.bun/install/cache")
{
PackageFilesPatterns = { new CopyFilePattern(packageFilesSourcePattern, "./") },
// bun supports passing script flags without the `--` separator.
CommandSeparator = null,
ResolvePackageScriptRuntimeImage = buildImage => buildImage,
DefaultBuildImage = DefaultBunImage,
})
.WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])
{
ProductionInstallArgs = "--production"
});
if (!resource.Resource.TryGetLastAnnotation(out _))
{
// bun is not available in the default Node.js base images used for publish-mode Dockerfile generation.
// We override the build image so that the install and build steps can execute with bun.
resource.WithAnnotation(new DockerfileBaseImageAnnotation
{
// Use a constant major version tag to keep builds deterministic.
BuildImage = "oven/bun:1",
});
}
AddInstaller(resource, install);
return resource;
}
///
/// Configures the JavaScript resource to use Deno as the package manager.
///
/// The type of the JavaScript application resource being configured.
/// The JavaScript application resource builder.
///
/// When , creates an installer resource that runs deno install before the application
/// starts. Defaults to : unlike npm/Bun, Deno does not require a separate install step —
/// deno run fetches and caches dependencies under DENO_DIR on first use — so no installer is wired by
/// default. Set to to pre-cache dependencies or to materialize a node_modules folder
/// for Node compatibility.
///
/// Additional command-line arguments passed to deno install.
/// A reference to the .
/// The resource builder.
///
/// Package scripts are run through Deno's task runner (deno task <name>) rather than run.
/// Publishing to a container requires Deno to be present in the build image. This method configures a Deno build
/// image (denoland/deno:2.9.0) when one is not already specified.
///
///
///
/// Run a Deno app using a deno.json task:
///
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddDenoApp("api", "../api", "main.ts")
/// .WithDeno()
/// .WithRunScript("dev");
///
/// builder.Build().Run();
///
///
[AspireExport]
[Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public static IResourceBuilder WithDeno(this IResourceBuilder resource, bool install = false, string[]? installArgs = null) where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(resource);
var workingDirectory = resource.Resource.WorkingDirectory;
installArgs ??= [];
// Copy the manifest and lockfile first for better layer caching in publish-mode Dockerfiles.
var packageFilesSourcePattern = "";
foreach (var manifest in new[] { "deno.json", "deno.jsonc", "deno.lock", "package.json" })
{
if (File.Exists(Path.Combine(workingDirectory, manifest)))
{
packageFilesSourcePattern += packageFilesSourcePattern.Length == 0 ? manifest : $" {manifest}";
}
}
var packageManager = new JavaScriptPackageManagerAnnotation("deno", runScriptCommand: "task")
{
// Deno's task runner forwards script arguments without requiring the `--` separator.
CommandSeparator = null,
ResolvePackageScriptRuntimeImage = buildImage => buildImage,
DefaultBuildImage = DefaultDenoImage,
// Deliberately no BuildKit cache mount. For npm/bun/pnpm the mount only holds a download cache
// while the resolved dependencies still land in /app/node_modules, so discarding the mount at the
// end of the build is harmless. For Deno, DENO_DIR *is* the dependency store, so mounting it would
// leave the runtime image with no dependencies and force a re-download on first run. Instead the
// cache is written into the build stage layer and copied into the runtime stage, which is what
// Deno's own Docker guidance recommends. See https://docs.deno.com/runtime/reference/docker/.
InitializeDockerBuildStage = stage => stage.Env("DENO_DIR", DenoCacheDirectory),
};
if (packageFilesSourcePattern.Length > 0)
{
packageManager.PackageFilesPatterns.Add(new CopyFilePattern(packageFilesSourcePattern, "./"));
}
resource
.WithAnnotation(packageManager)
.WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs]));
if (!resource.Resource.TryGetLastAnnotation(out _))
{
// Deno is not available in the default Node.js base images used for publish-mode Dockerfile generation.
// We override the build image so that install/build steps can execute with deno.
resource.WithAnnotation(new DockerfileBaseImageAnnotation
{
// Use a constant major version tag to keep builds deterministic.
BuildImage = DefaultDenoImage,
});
}
// Deno does not need an install step by default: `deno run` fetches and caches dependencies under DENO_DIR
// on first use. Only wire an installer resource when the caller explicitly opts in (e.g. to pre-cache deps
// or materialize node_modules for Node compatibility).
if (install)
{
AddInstaller(resource, install);
}
else
{
DisableExistingInstaller(resource);
}
return resource;
}
private static string[] GetDefaultBunInstallArgs(IResourceBuilder resource, bool hasBunLock) =>
resource.ApplicationBuilder.ExecutionContext.IsPublishMode && hasBunLock
? ["--frozen-lockfile"]
: [];
private static string GetDefaultNpmInstallCommand(IResourceBuilder resource) =>
resource.ApplicationBuilder.ExecutionContext.IsPublishMode &&
File.Exists(Path.Combine(resource.Resource.WorkingDirectory, "package-lock.json"))
? "ci"
: "install";
///
/// Configures the Node.js resource to use yarn as the package manager and optionally installs packages before the application starts.
///
/// The NodeAppResource.
/// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource.
/// The command-line arguments passed to "yarn install".
/// A reference to the .
/// The resource builder.
[AspireExport]
public static IResourceBuilder WithYarn(this IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(resource);
var workingDirectory = resource.Resource.WorkingDirectory;
var hasYarnLock = File.Exists(Path.Combine(workingDirectory, "yarn.lock"));
var hasYarnrc = File.Exists(Path.Combine(workingDirectory, ".yarnrc.yml"));
var hasYarnBerryDir = Directory.Exists(Path.Combine(workingDirectory, ".yarn"));
var hasYarnBerry = hasYarnrc || hasYarnBerryDir;
installArgs ??= GetDefaultYarnInstallArgs(resource, hasYarnLock, hasYarnBerry);
var cacheMount = hasYarnBerry ? ".yarn/cache" : "/root/.cache/yarn";
var packageManager = new JavaScriptPackageManagerAnnotation("yarn", runScriptCommand: "run", cacheMount)
{
// Yarn doesn't require "--" separator
// Yarn v1 strips the separator automatically but produces the warning suggesting to remove it.
// Later Yarn versions don't strip the separator and pass it to the script as-is, causing Vite to ignore subsequent arguments.
CommandSeparator = null,
};
var packageFilesSourcePattern = "package.json";
if (hasYarnLock)
{
packageFilesSourcePattern += " yarn.lock";
}
if (hasYarnrc)
{
packageFilesSourcePattern += " .yarnrc.yml";
}
packageManager.PackageFilesPatterns.Add(new CopyFilePattern(packageFilesSourcePattern, "./"));
if (hasYarnBerryDir)
{
packageManager.PackageFilesPatterns.Add(new CopyFilePattern(".yarn", "./.yarn"));
}
resource
.WithAnnotation(packageManager)
.WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])
{
ProductionInstallArgs = "--production"
});
AddInstaller(resource, install);
return resource;
}
private static string[] GetDefaultYarnInstallArgs(
IResourceBuilder resource,
bool hasYarnLock,
bool hasYarnBerry)
{
if (!resource.ApplicationBuilder.ExecutionContext.IsPublishMode ||
!hasYarnLock)
{
// Not publish mode or no yarn.lock, use default install args
return [];
}
if (hasYarnBerry)
{
// Yarn 2+ detected, --frozen-lockfile is deprecated in v2+, use --immutable instead
return ["--immutable"];
}
// Fallback: default to Yarn v1.x behavior
return ["--frozen-lockfile"];
}
///
/// Configures the Node.js resource to use pnpm as the package manager and optionally installs packages before the application starts.
///
/// The NodeAppResource.
/// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource.
/// The command-line arguments passed to "pnpm install".
/// A reference to the .
/// Thrown when package.json declares an invalid pnpm package manager version or integrity.
/// The resource builder.
[AspireExport]
public static IResourceBuilder WithPnpm(this IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(resource);
var workingDirectory = resource.Resource.WorkingDirectory;
var hasPnpmLock = File.Exists(Path.Combine(workingDirectory, "pnpm-lock.yaml"));
var hasPnpmWorkspace = File.Exists(Path.Combine(workingDirectory, "pnpm-workspace.yaml"));
var pnpmPackageManager = GetPnpmPackageManager(workingDirectory);
var initializeDockerStage = new Action(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 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 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@' or 'pnpm@+.'.");
}
}
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();
///
/// Adds a build script annotation to the resource builder using the specified command-line arguments.
///
/// The type of JavaScript application resource being configured.
/// The resource builder to which the build script annotation will be added.
/// The name of the script to be executed when the resource is built.
/// An array of command-line arguments to use for the build script.
/// The same resource builder instance with the build script annotation applied.
///
/// Use this method to specify custom build scripts for JavaScript application resources during
/// deployment.
///
[AspireExport]
public static IResourceBuilder WithBuildScript(this IResourceBuilder resource, string scriptName, string[]? args = null) where TResource : JavaScriptAppResource
{
return resource.WithAnnotation(new JavaScriptBuildScriptAnnotation(scriptName, args));
}
///
/// Adds a run script annotation to the specified JavaScript application resource builder, specifying the script to
/// execute and its arguments during run mode.
///
/// The type of the JavaScript application resource being configured. Must inherit from JavaScriptAppResource.
/// The resource builder to which the run script annotation will be added.
/// The name of the script to be executed when the resource is run.
/// An array of arguments to pass to the script.
/// The same resource builder instance with the run script annotation applied, enabling further configuration.
///
/// Use this method to specify a custom script and its arguments that should be executed when the resource is executed
/// in RunMode.
///
[AspireExport]
public static IResourceBuilder WithRunScript(this IResourceBuilder resource, string scriptName, string[]? args = null) where TResource : JavaScriptAppResource
{
return resource.WithAnnotation(new JavaScriptRunScriptAnnotation(scriptName, args));
}
[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder builder, string scriptPath, string launchConfigType)
where T : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(scriptPath);
var resource = builder.Resource;
var workingDirectory = Path.GetFullPath(resource.WorkingDirectory);
return builder.WithDebugSupport(
context =>
{
context.CancellationToken.ThrowIfCancellationRequested();
// Compute at run time so the launch config reflects the final annotation state
var hasRunScript = resource.TryGetLastAnnotation(out _);
var hasPackageManager = resource.TryGetLastAnnotation(out var pmAnnotation);
var denoCommandLine = launchConfigType == "deno" &&
resource.TryGetLastAnnotation(out var deno)
? deno
: null;
var isDenoTask = denoCommandLine?.Mode == DenoCommandMode.Task;
var isExplicitDenoDirectLaunch = denoCommandLine is
{
ModeSet: true,
Mode: DenoCommandMode.Run or DenoCommandMode.Serve
};
// WithRunScript annotations remain after an explicit Deno mode changes the emitted command.
// Match BuildDenoArgs precedence so launch metadata describes the final command.
var isPackageManagerScript = isDenoTask ||
(hasRunScript && hasPackageManager && !isExplicitDenoDirectLaunch);
var effectiveLaunchConfigType = launchConfigType == "deno" && hasRunScript && hasPackageManager
? GetJavaScriptPackageManagerLaunchConfigurationType(pmAnnotation!.ExecutableName)
: launchConfigType;
return Task.FromResult(new JavaScriptLaunchConfiguration(effectiveLaunchConfigType)
{
ScriptPath = Path.GetFullPath(scriptPath, workingDirectory),
Mode = context.Mode,
RuntimeExecutable = hasRunScript && hasPackageManager ? pmAnnotation!.ExecutableName : launchConfigType,
LaunchMethod = isPackageManagerScript ? JavaScriptLaunchConfiguration.LaunchMethodPackageManager : JavaScriptLaunchConfiguration.LaunchMethodDirect,
WorkingDirectory = workingDirectory
});
},
launchConfigType);
}
private static string GetJavaScriptPackageManagerLaunchConfigurationType(string packageManagerExecutable) => packageManagerExecutable switch
{
"bun" => "bun",
"deno" => "deno",
_ => "node",
};
[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder builder)
where T : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(builder);
var resource = builder.Resource;
var workingDirectory = Path.GetFullPath(resource.WorkingDirectory);
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.");
}
if (resource is DenoAppResource)
{
throw new InvalidOperationException(
$"Deno apps cannot be debugged through the Node dev-server debug path. '{resource.Name}' is a {nameof(DenoAppResource)}; use {nameof(AddDenoApp)}, which wires its own Deno 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(out var pmAnnotation))
{
packageManager = pmAnnotation.ExecutableName;
}
return new JavaScriptLaunchConfiguration("node")
{
ScriptPath = string.Empty,
Mode = mode,
RuntimeExecutable = packageManager,
LaunchMethod = JavaScriptLaunchConfiguration.LaunchMethodPackageManager,
WorkingDirectory = workingDirectory
};
},
"node");
}
///
/// Configures a browser debugger for the JavaScript application resource, enabling browser-based debugging
/// through a child resource that launches when the parent application is ready.
///
/// The type of the JavaScript application resource.
/// The resource builder for the JavaScript application.
/// The browser to use for debugging. Defaults to "msedge". Supported values include "msedge" and "chrome".
/// A reference to the for chaining additional configuration.
/// The resource builder.
///
/// This method creates a child that waits for the parent JavaScript
/// application to start, then launches a browser debug session targeting the parent's HTTP or HTTPS endpoint.
/// The parent resource must have at least one HTTP or HTTPS endpoint configured.
///
///
/// Thrown when the parent resource does not have an HTTP or HTTPS endpoint, or when the IDE extension
/// does not support browser debugging.
///
///
/// Add browser debugging to a JavaScript application:
///
/// var builder = DistributedApplication.CreateBuilder(args);
/// builder.AddViteApp("frontend", "./frontend")
/// .WithBrowserDebugger();
///
///
[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport]
public static IResourceBuilder WithBrowserDebugger(
this IResourceBuilder builder,
string browser = "msedge")
where T : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(builder);
// Validate that the extension supports browser debugging if we're running in an extension context
ValidateBrowserCapability(builder);
var parentResource = builder.Resource;
var debuggerResourceName = $"{parentResource.Name}-browser";
var debuggerResource = new BrowserDebuggerResource(debuggerResourceName, browser, parentResource.WorkingDirectory);
builder.ApplicationBuilder.AddResource(debuggerResource)
.WithParentRelationship(parentResource)
.WaitFor(builder)
.ExcludeFromManifest()
.WithDebugSupport(
mode =>
{
// Resolve endpoint at run time so dynamically added endpoints are reflected
EndpointAnnotation? endpointAnnotation = null;
if (parentResource.TryGetAnnotationsOfType(out var endpoints))
{
endpointAnnotation = endpoints.FirstOrDefault(e => e.UriScheme == "https")
?? endpoints.FirstOrDefault(e => e.UriScheme == "http");
}
if (endpointAnnotation is null)
{
throw new InvalidOperationException(
$"Resource '{parentResource.Name}' does not have an HTTP or HTTPS endpoint. Browser debugging requires an endpoint to navigate to.");
}
var endpointReference = parentResource.GetEndpoint(endpointAnnotation.Name);
return new BrowserLaunchConfiguration
{
Mode = mode,
Url = endpointReference.Url,
WebRoot = parentResource.WorkingDirectory,
Browser = browser
};
},
BrowserCapability);
return builder;
}
private static void ValidateBrowserCapability(IResourceBuilder builder) where T : IResource
{
var configuration = builder.ApplicationBuilder.Configuration;
try
{
if (configuration["DEBUG_SESSION_INFO"] is { } debugSessionInfoJson
&& JsonSerializer.Deserialize(debugSessionInfoJson) is { } info
&& info.SupportedLaunchConfigurations is not null
&& !info.SupportedLaunchConfigurations.Contains(BrowserCapability))
{
throw new InvalidOperationException(
"This version of the Aspire extension does not support browser debugging. Please update the Aspire extension to use browser debugging support with WithBrowserDebugger().");
}
}
catch (JsonException)
{
// If we can't parse the debug session info, skip validation
}
}
private sealed class DebugSessionCapabilities
{
[JsonPropertyName("supported_launch_configurations")]
public string[]? SupportedLaunchConfigurations { get; set; }
}
private static void AddInstaller(IResourceBuilder resource, bool install) where TResource : JavaScriptAppResource
{
// Only install packages if in run mode
if (resource.ApplicationBuilder.ExecutionContext.IsRunMode)
{
// Check if the installer resource already exists
var installerName = $"{resource.Resource.Name}-installer";
resource.ApplicationBuilder.TryCreateResourceBuilder(installerName, out var existingResource);
if (existingResource is not null)
{
// Installer already exists, update its configuration based on install parameter. Package manager
// methods are composable (for example `.WithDeno(install: false).WithDeno(install: true)`), so the
// final call has to be able to re-enable a previously disabled installer, not just disable it.
if (install)
{
EnableInstaller(resource, existingResource);
}
else
{
DisableInstaller(resource, existingResource);
}
return;
}
var installer = new JavaScriptInstallerResource(installerName, resource.Resource.WorkingDirectory);
installer.Annotations.Add(NameValidationPolicyAnnotation.None);
var installerBuilder = resource.ApplicationBuilder.AddResource(installer)
.WithParentRelationship(resource.Resource)
.ExcludeFromManifest()
.WithCertificateTrustScope(CertificateTrustScope.None);
resource.ApplicationBuilder.OnBeforeStart((_, _) =>
{
// set the installer's working directory to match the resource's working directory
// and set the install command and args based on the resource's annotations
if (!resource.Resource.TryGetLastAnnotation(out var packageManager) ||
!resource.Resource.TryGetLastAnnotation(out var installCommand))
{
throw new InvalidOperationException("JavaScriptPackageManagerAnnotation and JavaScriptInstallCommandAnnotation are required when installing packages.");
}
installerBuilder
.WithCommand(packageManager.ExecutableName)
.WithWorkingDirectory(resource.Resource.WorkingDirectory)
.WithArgs(installCommand.Args);
return Task.CompletedTask;
});
if (install)
{
// Make the parent resource wait for the installer to complete
resource.WaitForCompletion(installerBuilder);
}
else
{
// Add WithExplicitStart when install is false
// Note: No need to remove wait annotations here since WaitForCompletion was never called
installerBuilder.WithExplicitStart();
}
resource.WithAnnotation(new JavaScriptPackageInstallerAnnotation(installer));
}
}
private static void DisableExistingInstaller(IResourceBuilder resource) where TResource : JavaScriptAppResource
{
if (!resource.ApplicationBuilder.ExecutionContext.IsRunMode)
{
return;
}
var installerName = $"{resource.Resource.Name}-installer";
resource.ApplicationBuilder.TryCreateResourceBuilder(installerName, out var existingResource);
if (existingResource is not null)
{
DisableInstaller(resource, existingResource);
}
}
private static void DisableInstaller(IResourceBuilder resource, IResourceBuilder installer) where TResource : JavaScriptAppResource
{
resource.Resource.Annotations.OfType()
.Where(w => w.Resource == installer.Resource)
.ToList()
.ForEach(w => resource.Resource.Annotations.Remove(w));
installer.WithExplicitStart();
}
private static void EnableInstaller(IResourceBuilder resource, IResourceBuilder installer) where TResource : JavaScriptAppResource
{
// Undo WithExplicitStart so the installer starts automatically again.
installer.Resource.Annotations.OfType()
.ToList()
.ForEach(a => installer.Resource.Annotations.Remove(a));
// WaitForCompletion adds a new WaitAnnotation each time, so only restore the relationship when the
// previous disable removed it.
if (!resource.Resource.Annotations.OfType().Any(w => w.Resource == installer.Resource))
{
resource.WaitForCompletion(installer);
}
}
private static string GetDefaultBaseImage(string appDirectory, string defaultSuffix, IServiceProvider serviceProvider)
{
var logger = serviceProvider.GetService>() ?? NullLogger.Instance;
var nodeVersion = ResolveNodeVersion(appDirectory, logger);
return $"node:{nodeVersion}-{defaultSuffix}";
}
private static string GetContainerFilesSourcePath(string outputPath)
{
var normalizedPath = NormalizeRelativePath(outputPath);
return string.IsNullOrEmpty(normalizedPath) || normalizedPath == "."
? "/app"
: $"/app/{normalizedPath}";
}
private static readonly string[] s_nextConfigFileNames = ["next.config.ts", "next.config.js", "next.config.mjs"];
///
/// Builds a service discovery URL for the given resource, preferring HTTPS when available.
/// Mirrors the logic in YarpCluster.BuildEndpointUri.
///
private static string BuildServiceDiscoveryUrl(IResourceWithServiceDiscovery resource)
{
var endpoints = resource.GetEndpoints();
var hasHttpsEndpoint = endpoints.Any(e => e.Exists && e.IsHttps);
var hasHttpEndpoint = endpoints.Any(e => e.Exists && e.IsHttp);
var scheme = (hasHttpsEndpoint, hasHttpEndpoint) switch
{
(true, true) => "https+http",
(true, false) => "https",
(false, true) => "http",
_ => throw new ArgumentException("Cannot find a http or https endpoint for this resource.", nameof(resource))
};
return $"{scheme}://{resource.Name}";
}
///
/// Validates that the Next.js config file contains output: "standalone".
///
internal static void ValidateNextJsStandaloneOutput(string appDirectory)
{
foreach (var configFileName in s_nextConfigFileNames)
{
var configPath = Path.Combine(appDirectory, configFileName);
if (!File.Exists(configPath))
{
continue;
}
try
{
var content = File.ReadAllText(configPath);
// Check for quoted "standalone" (double or single quotes) to reduce false positives
if (!content.Contains("\"standalone\"") && !content.Contains("'standalone'"))
{
throw new InvalidOperationException(
$"The Next.js config file '{configFileName}' does not contain 'output: \"standalone\"'. " +
"AddNextJsApp requires Next.js standalone output mode to generate a working Dockerfile. " +
"Add 'output: \"standalone\"' to the nextConfig object in your Next.js config file.");
}
}
catch (IOException)
{
// If we can't read the config, skip the check — the Docker build will surface the error.
}
return;
}
throw new InvalidOperationException(
"No Next.js configuration file found. AddNextJsApp expects one of: " +
string.Join(", ", s_nextConfigFileNames));
}
private static void ValidateApiPath(string apiPath)
{
foreach (var c in apiPath)
{
if (!char.IsAsciiLetterOrDigit(c) && c is not '/' and not '-' and not '_')
{
throw new ArgumentException($"The apiPath must contain only URL-safe path characters (alphanumeric, '/', '-', '_'). Invalid character: '{c}'", nameof(apiPath));
}
}
}
///
/// Walks up from to find the nearest node_modules directory.
///
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);
}
///
/// Resolves the Node.js version to use for a project by checking common configuration files.
///
/// The working directory of the Node.js project.
/// The logger for diagnostic messages.
/// The resolved Node.js major version number as a string.
private static string ResolveNodeVersion(string workingDirectory, ILogger logger)
{
// Follow the same shape as Cloud Native Buildpacks-style tooling for Node selection:
// pinned toolchain files (.nvmrc, .node-version, .tool-versions) are treated as
// authoritative runtime intent, while package.json engines.node is compatibility
// metadata rather than a deployment image pin. If there is no explicit toolchain pin,
// generated Dockerfiles fall back to Aspire's preferred default Node major.
if (TryDetectPinnedNodeVersion(workingDirectory, logger, out var pinnedNodeVersion))
{
return pinnedNodeVersion;
}
logger.LogDebug("No Node.js version detected, using default version {DefaultVersion}", DefaultNodeVersion);
return DefaultNodeVersion;
}
private static bool TryDetectPinnedNodeVersion(string workingDirectory, ILogger logger, out string nodeVersion)
{
nodeVersion = string.Empty;
// Check .nvmrc file
var nvmrcPath = Path.Combine(workingDirectory, ".nvmrc");
if (File.Exists(nvmrcPath))
{
var versionString = File.ReadAllText(nvmrcPath).Trim();
if (TryParseNodeVersion(versionString, out var version))
{
logger.LogDebug("Detected Node.js version {Version} from .nvmrc file", version);
nodeVersion = version;
return true;
}
}
// Check .node-version file
var nodeVersionPath = Path.Combine(workingDirectory, ".node-version");
if (File.Exists(nodeVersionPath))
{
var versionString = File.ReadAllText(nodeVersionPath).Trim();
if (TryParseNodeVersion(versionString, out var version))
{
logger.LogDebug("Detected Node.js version {Version} from .node-version file", version);
nodeVersion = version;
return true;
}
}
// Check .tool-versions file (asdf)
var toolVersionsPath = Path.Combine(workingDirectory, ".tool-versions");
if (File.Exists(toolVersionsPath))
{
var lines = File.ReadAllLines(toolVersionsPath);
foreach (var line in lines)
{
var trimmedLine = line.Trim();
var parts = trimmedLine.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 1 &&
(string.Equals(parts[0], "nodejs", StringComparison.Ordinal) ||
string.Equals(parts[0], "node", StringComparison.Ordinal)))
{
if (TryParseNodeVersion(parts[1], out var version))
{
logger.LogDebug("Detected Node.js version {Version} from .tool-versions file", version);
nodeVersion = version;
return true;
}
}
}
}
return false;
}
///
/// Attempts to parse a Node.js version string and extract the major version number.
///
/// The version string to parse (e.g., "22", "v22.1.0", ">=20.12", "^18.0.0").
/// The extracted major version number as a string.
/// True if the version was successfully parsed, false otherwise.
private static bool TryParseNodeVersion(string versionString, out string majorVersion)
{
majorVersion = string.Empty;
if (string.IsNullOrWhiteSpace(versionString))
{
return false;
}
// Remove common prefixes and operators (handle multi-character operators first)
var cleaned = versionString.Trim();
string[] operators = [">=", "<=", "==", ">", "<", "=", "~", "^", "v", "V"];
foreach (var op in operators)
{
if (cleaned.StartsWith(op, StringComparison.Ordinal))
{
cleaned = cleaned.Substring(op.Length).TrimStart();
break;
}
}
var cleanedVersion = cleaned.Split('.', '-', ' ')[0]; // Take only the major version part
// Try to parse as integer
if (int.TryParse(cleanedVersion, NumberStyles.None, CultureInfo.InvariantCulture, out var majorVersionNumber) && majorVersionNumber > 0)
{
majorVersion = majorVersionNumber.ToString(CultureInfo.InvariantCulture);
return true;
}
return false;
}
}