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
7 changes: 6 additions & 1 deletion playground/AspireWithNode/AspireWithNode.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

var builder = DistributedApplication.CreateBuilder(args);

builder.AddDockerComposeEnvironment("compose");

var pass = builder.AddParameter("pass", "p@ssw0rd1");

var cache = builder
Expand All @@ -12,14 +14,17 @@

var weatherapi = builder.AddProject<Projects.AspireWithNode_AspNetCoreApi>("weatherapi");

#pragma warning disable ASPIREJAVASCRIPT001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var frontend = builder.AddJavaScriptApp("frontend", "../NodeFrontend", "watch")
.WithPnpm()
.WithReference(weatherapi)
.WaitFor(weatherapi)
.WithReference(cache)
.WaitFor(cache)
.WithHttpEndpoint(env: "PORT")
.WithExternalHttpEndpoints()
.PublishAsDockerFile();
.PublishAsNpmScript("start");
#pragma warning restore ASPIREJAVASCRIPT001

var launchProfile = builder.Configuration["DOTNET_LAUNCH_PROFILE"];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

<ItemGroup>
<AspireProjectOrPackageReference Include="Aspire.Hosting.AppHost" />
<AspireProjectOrPackageReference Include="Aspire.Hosting.Docker" />
<AspireProjectOrPackageReference Include="Aspire.Hosting.JavaScript" />
<AspireProjectOrPackageReference Include="Aspire.Hosting.Redis" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"type": "container.v1",
"build": {
"context": "../NodeFrontend",
"dockerfile": "../NodeFrontend/Dockerfile"
"dockerfile": "frontend.Dockerfile"
},
"env": {
"NODE_ENV": "production",
Expand All @@ -68,7 +68,9 @@
"CACHE_PORT": "{cache.bindings.tcp.port}",
"CACHE_PASSWORD": "{pass.value}",
"CACHE_URI": "{cache.bindings.tcp.scheme}://:{pass-uri-encoded.value}@{cache.bindings.tcp.host}:{cache.bindings.tcp.port}",
"PORT": "{frontend.bindings.http.targetPort}"
"PORT": "{frontend.bindings.http.targetPort}",
"HOST": "0.0.0.0",
"HOSTNAME": "0.0.0.0"
},
"bindings": {
"http": {
Expand All @@ -90,4 +92,4 @@
"connectionString": ""
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FROM node:22-slim AS build
WORKDIR /app
RUN corepack enable pnpm
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,target=/pnpm/store pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build

FROM node:22-slim AS prod-deps
WORKDIR /app
RUN corepack enable pnpm
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,target=/pnpm/store pnpm install --frozen-lockfile --prod

FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=build /app /app
COPY --from=prod-deps /app/node_modules ./node_modules
RUN corepack enable pnpm && pnpm --version
ENV NODE_ENV=production
ENTRYPOINT ["sh","-c","exec pnpm run start"]
13 changes: 0 additions & 13 deletions playground/AspireWithNode/NodeFrontend/Dockerfile

This file was deleted.

9 changes: 7 additions & 2 deletions playground/AspireWithNode/NodeFrontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,27 @@
"name": "nodefrontend",
"version": "1.0.0",
"type": "module",
"packageManager": "pnpm@10.30.1",
"main": "app.js",
"engines": {
"node": ">=20.12"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node --watch --import ./instrumentation.js app.js",
"watch": "npm install && nodemon --import ./instrumentation.js app.js"
"build": "echo \"no build needed\"",
"start": "node --import ./instrumentation.js app.js",
"watch": "nodemon --import ./instrumentation.js app.js"
},
"dependencies": {
"@godaddy/terminus": "^4.12.1",
"@grpc/grpc-js": "^1.14.3",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.218.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.218.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.218.0",
"@opentelemetry/instrumentation-express": "^0.66.0",
"@opentelemetry/instrumentation-http": "^0.218.0",
"@opentelemetry/instrumentation-redis": "^0.66.0",
"@opentelemetry/sdk-logs": "^0.218.0",
"@opentelemetry/sdk-metrics": "^2.6.1",
Expand Down
9 changes: 9 additions & 0 deletions playground/AspireWithNode/NodeFrontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions playground/AspireWithNode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ The app consists of two services:
- **NodeFrontend**: This is a simple Express-based Node.js app that renders a table of weather forecasts retrieved from a backend API and utilizes a Redis cache.
- **AspireWithNode.AspNetCoreApi**: This is an HTTP API that returns randomly generated weather forecast data.

The frontend uses pnpm and Aspire's generated JavaScript Dockerfile support. The AppHost configures it with `WithPnpm()` and `PublishAsNpmScript("start")`, so publish mode builds a production container from the package metadata and runs the app through the package manager script.

The AppHost also includes Docker Compose publishing support. To deploy this sample locally with Docker Compose, run:

```shell
aspire deploy
```

## Pre-requisites

- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
Expand Down
34 changes: 31 additions & 3 deletions src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,22 @@ private static void AddInstallCommand(this DockerfileStage builderStage, JavaScr
}
}

private static string GetNpmScriptRuntimeImage(
string appDirectory,
IServiceProvider services,
DockerfileBaseImageAnnotation? baseImageAnnotation,
JavaScriptPackageManagerAnnotation packageManager,
string buildImage)
{
if (!string.IsNullOrEmpty(baseImageAnnotation?.RuntimeImage))
{
return baseImageAnnotation.RuntimeImage;
}

return packageManager.ResolveNpmScriptRuntimeImage?.Invoke(buildImage)
?? GetDefaultBaseImage(appDirectory, "alpine", services);
}

private static IResourceBuilder<TResource> CreateDefaultJavaScriptAppBuilder<TResource>(
this IDistributedApplicationBuilder builder,
TResource resource,
Expand Down Expand Up @@ -783,7 +799,7 @@ private static IResourceBuilder<TResource> CreateDefaultJavaScriptAppBuilder<TRe
}
case JavaScriptPublishMode.NpmScript:
{
var runtimeImage = baseImageAnnotation?.RuntimeImage ?? GetDefaultBaseImage(appDirectory, "alpine", dockerfileContext.Services);
var runtimeImage = GetNpmScriptRuntimeImage(appDirectory, dockerfileContext.Services, baseImageAnnotation, packageManager, baseImage);

// Production dependencies stage for optimized image
var prodDepsStage = dockerfileContext.Builder
Expand Down Expand Up @@ -828,11 +844,15 @@ private static IResourceBuilder<TResource> CreateDefaultJavaScriptAppBuilder<TRe
? $"{packageManager.ExecutableName} {packageManager.ScriptCommand ?? "run"} {publishMode.StartScriptName}"
: $"{packageManager.ExecutableName} {packageManager.ScriptCommand ?? "run"} {publishMode.StartScriptName} {publishMode.RunScriptArguments}";

dockerfileContext.Builder
var runtimeStage = dockerfileContext.Builder
.From(runtimeImage, "runtime")
.WorkDir("/app")
.CopyFrom("build", "/app", "/app")
.CopyFrom("prod-deps", "/app/node_modules", "./node_modules")
.CopyFrom("prod-deps", "/app/node_modules", "./node_modules");

packageManager.InitializeDockerRuntimeStage?.Invoke(runtimeStage);

runtimeStage
.Env("NODE_ENV", "production")
.Entrypoint(["sh", "-c", $"exec {runCommand}"]);
break;
Expand Down Expand Up @@ -1319,6 +1339,7 @@ public static IResourceBuilder<TResource> WithNpm<TResource>(this IResourceBuild
/// Bun forwards script arguments without requiring the <c>--</c> command separator, so this method configures the resource to omit it.
/// When publishing and a bun lockfile (<c>bun.lock</c> or <c>bun.lockb</c>) is present, <c>--frozen-lockfile</c> is used by default.
/// Publishing to a container requires Bun to be present in the build image. This method configures a Bun build image when one is not already specified.
/// <see cref="PublishAsNpmScript{TResource}"/> also uses the Bun image for the runtime stage unless a custom runtime image is configured.
/// To use a specific Bun version, configure a custom build image (for example, <c>oven/bun:&lt;tag&gt;</c>) using <see cref="ContainerResourceBuilderExtensions.WithDockerfileBaseImage{T}(IResourceBuilder{T}, string?, string?)"/>.
/// </remarks>
/// <example>
Expand Down Expand Up @@ -1360,6 +1381,7 @@ public static IResourceBuilder<TResource> WithBun<TResource>(this IResourceBuild
PackageFilesPatterns = { new CopyFilePattern(packageFilesSourcePattern, "./") },
// bun supports passing script flags without the `--` separator.
CommandSeparator = null,
ResolveNpmScriptRuntimeImage = buildImage => buildImage,
})
.WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])
{
Expand Down Expand Up @@ -1508,6 +1530,12 @@ public static IResourceBuilder<TResource> WithPnpm<TResource>(this IResourceBuil
CommandSeparator = null,
// pnpm is not included in the Node.js Docker image by default, so we need to enable it via corepack
InitializeDockerBuildStage = stage => stage.Run("corepack enable pnpm"),
InitializeDockerRuntimeStage = stage =>
{
// Corepack's shim is not enough by itself: without invoking pnpm during the image build,
// the first container start can try to download pnpm before running the app.
stage.Run("corepack enable pnpm && pnpm --version");
},
})
.WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,15 @@ public sealed class JavaScriptPackageManagerAnnotation(string executableName, st
/// </summary>
[Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public Action<DockerfileStage>? InitializeDockerBuildStage { get; init; }

/// <summary>
/// Gets or sets a callback to initialize the Docker runtime stage before configuring the entrypoint.
/// </summary>
[Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
internal Action<DockerfileStage>? InitializeDockerRuntimeStage { get; init; }

/// <summary>
/// Gets or sets a callback to resolve the default <c>PublishAsNpmScript</c> runtime image from the build image.
/// </summary>
internal Func<string, string>? ResolveNpmScriptRuntimeImage { get; init; }
}
109 changes: 109 additions & 0 deletions tests/Aspire.Hosting.JavaScript.Tests/AddJavaScriptAppTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,35 @@ public async Task VerifyPnpmDockerfile(bool hasLockFile)
await Verify(dockerfileContents);
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task VerifyPnpmDockerfileWhenPublishedAsNpmScript(bool hasLockFile)
{
using var tempDir = new TestTempDirectory();
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true);

var appDir = Path.Combine(tempDir.Path, "js");
Directory.CreateDirectory(appDir);

if (hasLockFile)
{
File.WriteAllText(Path.Combine(appDir, "pnpm-lock.yaml"), string.Empty);
}

var pnpmApp = builder.AddJavaScriptApp("js", appDir)
.WithPnpm(installArgs: ["--prefer-frozen-lockfile"])
.WithBuildScript("mybuild")
.PublishAsNpmScript("start");

await ManifestUtils.GetManifest(pnpmApp.Resource, tempDir.Path);

var dockerfilePath = Path.Combine(tempDir.Path, "js.Dockerfile");
var dockerfileContents = File.ReadAllText(dockerfilePath);

await Verify(dockerfileContents);
}

[Fact]
public async Task PublishWithExistingDockerfileThrowsWhenRunScriptNameIsExplicit()
{
Expand Down Expand Up @@ -401,6 +430,86 @@ public async Task VerifyPnpmDockerfileBuildSucceeds()
Assert.True(process.ExitCode == 0, $"Docker build failed with exit code {process.ExitCode}.\nStdout: {stdout}\nStderr: {stderr}");
}

[Fact]
[RequiresFeature(TestFeature.Docker | TestFeature.DockerPluginBuildx)]
[OuterloopTest("Builds and runs a Docker image to verify the generated pnpm PublishAsNpmScript Dockerfile works")]
public async Task VerifyPnpmDockerfileWhenPublishedAsNpmScriptRunsWithoutNetwork()
{
using var tempDir = new TestTempDirectory();
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true);

var appDir = Path.Combine(tempDir.Path, "pnpm-app");
Directory.CreateDirectory(appDir);

var packageJson = """
{
"name": "pnpm-runtime-test-app",
"version": "1.0.0",
"scripts": {
"build": "echo 'build completed'",
"start": "node -e \"console.log('runtime ok')\""
}
}
""";
await File.WriteAllTextAsync(Path.Combine(appDir, "package.json"), packageJson);

var pnpmApp = builder.AddJavaScriptApp("pnpm-app", appDir)
.WithPnpm()
.WithBuildScript("build")
.PublishAsNpmScript("start");

await ManifestUtils.GetManifest(pnpmApp.Resource, tempDir.Path);

var dockerfilePath = Path.Combine(tempDir.Path, "pnpm-app.Dockerfile");
Assert.True(File.Exists(dockerfilePath), $"Dockerfile should exist at {dockerfilePath}");

var dockerfileContent = await File.ReadAllTextAsync(dockerfilePath);
Assert.Contains("RUN corepack enable pnpm && pnpm --version", dockerfileContent);

var dockerfileInContext = Path.Combine(appDir, "Dockerfile");
await File.WriteAllTextAsync(dockerfileInContext, dockerfileContent);

var imageName = $"aspire-pnpm-runtime-test-{Guid.NewGuid():N}";

try
{
var buildResult = await RunDockerCommandAsync($"build --network=host -t {imageName} -f Dockerfile .", appDir);
Assert.True(buildResult.ExitCode == 0, $"Docker build failed with exit code {buildResult.ExitCode}.\nStdout: {buildResult.Stdout}\nStderr: {buildResult.Stderr}");

var runResult = await RunDockerCommandAsync($"run --rm --network=none {imageName}", appDir);
Assert.True(runResult.ExitCode == 0, $"Docker run failed with exit code {runResult.ExitCode}.\nStdout: {runResult.Stdout}\nStderr: {runResult.Stderr}");
Assert.Contains("runtime ok", runResult.Stdout);
}
finally
{
await RunDockerCommandAsync($"rmi {imageName}", appDir);
}
}

private static async Task<(int ExitCode, string Stdout, string Stderr)> RunDockerCommandAsync(string arguments, string workingDirectory)
{
var processStartInfo = new ProcessStartInfo
{
FileName = "docker",
Arguments = arguments,
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};

using var process = Process.Start(processStartInfo);
Assert.NotNull(process);

var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();

await process.WaitForExitAsync(TestContext.Current.CancellationToken);

return (process.ExitCode, await stdoutTask, await stderrTask);
}

private static string CreateJavaScriptAppWithDockerfile(string rootDirectory)
{
var appDir = Path.Combine(rootDirectory, "js");
Expand Down
Loading
Loading