Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,80 @@ public static IResourceBuilder<TResource> WithNpm<TResource>(this IResourceBuild
return resource;
}

/// <summary>
/// Configures the JavaScript resource to use Bun as the package manager and optionally installs packages before the application starts.
/// </summary>
/// <param name="resource">The JavaScript application resource builder.</param>
/// <param name="install">When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource.</param>
/// <param name="installArgs">Additional command-line arguments passed to "bun install". When null, defaults are applied based on publish mode and lockfile presence.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// Bun forwards script arguments without requiring the <c>--</c> command separator, so this method configures the resource to omit it.
/// When publishing and a bun lockfile (<c>bun.lock</c> or <c>bun.lockb</c>) is present, <c>--frozen-lockfile</c> is used by default.
/// Publishing to a container requires Bun to be present in the build image. This method configures a Bun build image when one is not already specified.
/// To use a specific Bun version, configure a custom build image (for example, <c>oven/bun:&lt;tag&gt;</c>) using <see cref="ContainerResourceBuilderExtensions.WithDockerfileBaseImage{T}(IResourceBuilder{T}, string?, string?)"/>.
/// </remarks>
/// <example>
/// Run a Vite app using Bun as the package manager:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddViteApp("frontend", "./frontend")
/// .WithBun()
/// .WithDockerfileBaseImage(buildImage: "oven/bun:latest"); // To use a specific Bun image
///
/// builder.Build().Run();
/// </code>
/// </example>
public static IResourceBuilder<TResource> WithBun<TResource>(this IResourceBuilder<TResource> resource, bool install = true, string[]? installArgs = null) where TResource : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(resource);

var workingDirectory = resource.Resource.WorkingDirectory;
var hasBunLock = File.Exists(Path.Combine(workingDirectory, "bun.lock")) ||
File.Exists(Path.Combine(workingDirectory, "bun.lockb"));

installArgs ??= GetDefaultBunInstallArgs(resource, hasBunLock);

var packageFilesSourcePattern = "package.json";
if (File.Exists(Path.Combine(workingDirectory, "bun.lock")))
{
packageFilesSourcePattern += " bun.lock";
}
if (File.Exists(Path.Combine(workingDirectory, "bun.lockb")))
{
packageFilesSourcePattern += " bun.lockb";
}

resource
.WithAnnotation(new JavaScriptPackageManagerAnnotation("bun", runScriptCommand: "run", cacheMount: "/root/.bun/install/cache")
{
PackageFilesPatterns = { new CopyFilePattern(packageFilesSourcePattern, "./") },
// bun supports passing script flags without the `--` separator.
CommandSeparator = null,
})
.WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs]));

if (!resource.Resource.TryGetLastAnnotation<DockerfileBaseImageAnnotation>(out _))
{
// bun is not available in the default Node.js base images used for publish-mode Dockerfile generation.
// We override the build image so that the install and build steps can execute with bun.
resource.WithAnnotation(new DockerfileBaseImageAnnotation
{
// Use a constant major version tag to keep builds deterministic.
BuildImage = "oven/bun:1",
});
}

AddInstaller(resource, install);
return resource;
}

private static string[] GetDefaultBunInstallArgs(IResourceBuilder<JavaScriptAppResource> resource, bool hasBunLock) =>
resource.ApplicationBuilder.ExecutionContext.IsPublishMode && hasBunLock
? ["--frozen-lockfile"]
: [];

private static string GetDefaultNpmInstallCommand(IResourceBuilder<JavaScriptAppResource> resource) =>
resource.ApplicationBuilder.ExecutionContext.IsPublishMode &&
File.Exists(Path.Combine(resource.Resource.WorkingDirectory, "package-lock.json"))
Expand Down
32 changes: 32 additions & 0 deletions tests/Aspire.Hosting.JavaScript.Tests/AddViteAppWithPnpmTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DistributedApplicationModel>();

var nodeResource = Assert.Single(appModel.Resources.OfType<JavaScriptAppResource>());

Assert.True(nodeResource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(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<CommandLineArgsCallbackAnnotation>().Single();
var args = new List<object>();
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()
{
Expand Down
15 changes: 15 additions & 0 deletions tests/Aspire.Hosting.JavaScript.Tests/PackageInstallationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<JavaScriptInstallCommandAnnotation>(out var installCommand));
Assert.Equal(["install", "--frozen-lockfile"], installCommand.Args);
}

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")]
private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken);
}
Loading