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