diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index 9455f36766a..2a183dd13ca 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -712,6 +712,80 @@ public static IResourceBuilder WithNpm(this IResourceBuild 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 . + /// + /// 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. + /// 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(); + /// + /// + 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, + }) + .WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])); + + if (!resource.Resource.TryGetLastAnnotation(out _)) + { + // bun is not available in the default Node.js base images used for publish-mode Dockerfile generation. + // We override the build image so that the install and build steps can execute with bun. + resource.WithAnnotation(new DockerfileBaseImageAnnotation + { + // Use a constant major version tag to keep builds deterministic. + BuildImage = "oven/bun:1", + }); + } + + AddInstaller(resource, install); + return resource; + } + + private static string[] GetDefaultBunInstallArgs(IResourceBuilder resource, bool hasBunLock) => + resource.ApplicationBuilder.ExecutionContext.IsPublishMode && hasBunLock + ? ["--frozen-lockfile"] + : []; + private static string GetDefaultNpmInstallCommand(IResourceBuilder resource) => resource.ApplicationBuilder.ExecutionContext.IsPublishMode && File.Exists(Path.Combine(resource.Resource.WorkingDirectory, "package-lock.json")) diff --git a/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppWithPnpmTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppWithPnpmTests.cs index dbee7b80515..b322beb7ab5 100644 --- a/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppWithPnpmTests.cs +++ b/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppWithPnpmTests.cs @@ -43,6 +43,38 @@ public void AddViteApp_WithPnpm_DoesNotIncludeSeparator() arg => { }); // port value is dynamic } + [Fact] + public void AddViteApp_WithBun_DoesNotIncludeSeparator() + { + var builder = DistributedApplication.CreateBuilder(); + + var viteApp = builder.AddViteApp("test-app", "./test-app") + .WithBun(); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var nodeResource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(nodeResource.TryGetLastAnnotation(out var packageManager)); + Assert.Equal("bun", packageManager.ExecutableName); + Assert.Equal("run", packageManager.ScriptCommand); + + // Get the command line args annotation to inspect the args callback + var commandLineArgsAnnotation = nodeResource.Annotations.OfType().Single(); + var args = new List(); + var context = new CommandLineArgsCallbackContext(args, nodeResource); + commandLineArgsAnnotation.Callback(context); + + // bun supports passing script flags without the `--` separator. + Assert.Collection(args, + arg => Assert.Equal("run", arg), + arg => Assert.Equal("dev", arg), + arg => Assert.Equal("--port", arg), + arg => { }); // port value is dynamic + } + [Fact] public void AddViteApp_WithNpm_IncludesSeparator() { diff --git a/tests/Aspire.Hosting.JavaScript.Tests/PackageInstallationTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/PackageInstallationTests.cs index 7f87fe39fb2..78316522cfd 100644 --- a/tests/Aspire.Hosting.JavaScript.Tests/PackageInstallationTests.cs +++ b/tests/Aspire.Hosting.JavaScript.Tests/PackageInstallationTests.cs @@ -546,6 +546,21 @@ public void WithPnpm_DefaultsArgsInPublishMode() Assert.Equal(["install", "--frozen-lockfile"], installCommand.Args); } + [Fact] + public void WithBun_DefaultsArgsInPublishMode() + { + using var tempDir = new TestTempDirectory(); + File.WriteAllText(Path.Combine(tempDir.Path, "bun.lock"), "empty"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var app = builder.AddViteApp("test-app", tempDir.Path) + .WithBun(); + + Assert.True(app.Resource.TryGetLastAnnotation(out var installCommand)); + Assert.Equal(["install", "--frozen-lockfile"], installCommand.Args); + } + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); }