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