From d9500a14be781df586149b3369c49a8e40ca6d6e Mon Sep 17 00:00:00 2001 From: Rickylabs Date: Thu, 2 Jul 2026 19:26:16 +0200 Subject: [PATCH 01/83] Add AddDenoApp for Deno-native apps --- .../AspireWithDeno/DenoFrontend/deno.json | 5 + .../AspireWithDeno/DenoFrontend/main.ts | 14 + playground/AspireWithDeno/README.md | 33 ++ playground/AspireWithDeno/apphost.mts | 24 + playground/AspireWithDeno/aspire.config.json | 10 + playground/AspireWithDeno/package.json | 19 + playground/AspireWithDeno/tsconfig.json | 21 + .../DenoAppResource.cs | 16 + .../JavaScriptHostingExtensions.cs | 328 ++++++++++++++ .../api/Aspire.Hosting.JavaScript.ats.txt | 3 + .../api/Aspire.Hosting.JavaScript.cs | 13 + .../AddDenoAppTests.cs | 423 ++++++++++++++++++ .../DenoAppFixture.cs | 144 ++++++ .../DenoFunctionalTests.cs | 39 ++ ...mitsPerDockerfileDockerignore.verified.txt | 13 + ...DockerfileWithCustomBaseImage.verified.txt | 13 + ...file_includePackageJson=False.verified.txt | 13 + ...rfile_includePackageJson=True.verified.txt | 13 + ...ddDenoAppTests.VerifyManifest.verified.txt | 23 + 19 files changed, 1167 insertions(+) create mode 100644 playground/AspireWithDeno/DenoFrontend/deno.json create mode 100644 playground/AspireWithDeno/DenoFrontend/main.ts create mode 100644 playground/AspireWithDeno/README.md create mode 100644 playground/AspireWithDeno/apphost.mts create mode 100644 playground/AspireWithDeno/aspire.config.json create mode 100644 playground/AspireWithDeno/package.json create mode 100644 playground/AspireWithDeno/tsconfig.json create mode 100644 src/Aspire.Hosting.JavaScript/DenoAppResource.cs create mode 100644 tests/Aspire.Hosting.JavaScript.Tests/AddDenoAppTests.cs create mode 100644 tests/Aspire.Hosting.JavaScript.Tests/DenoAppFixture.cs create mode 100644 tests/Aspire.Hosting.JavaScript.Tests/DenoFunctionalTests.cs create mode 100644 tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfileEmitsPerDockerfileDockerignore.verified.txt create mode 100644 tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfileWithCustomBaseImage.verified.txt create mode 100644 tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfile_includePackageJson=False.verified.txt create mode 100644 tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfile_includePackageJson=True.verified.txt create mode 100644 tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyManifest.verified.txt diff --git a/playground/AspireWithDeno/DenoFrontend/deno.json b/playground/AspireWithDeno/DenoFrontend/deno.json new file mode 100644 index 00000000000..4619b3e67ba --- /dev/null +++ b/playground/AspireWithDeno/DenoFrontend/deno.json @@ -0,0 +1,5 @@ +{ + "tasks": { + "start": "deno run -A main.ts --from-task" + } +} diff --git a/playground/AspireWithDeno/DenoFrontend/main.ts b/playground/AspireWithDeno/DenoFrontend/main.ts new file mode 100644 index 00000000000..c43beecdb19 --- /dev/null +++ b/playground/AspireWithDeno/DenoFrontend/main.ts @@ -0,0 +1,14 @@ +// Minimal Deno HTTP server used by the AspireWithDeno playground and DenoFunctionalTests. +// Distinguishes between direct invocation (`deno run -A main.ts`) and task invocation +// (`deno task start`) by checking for the `--from-task` argument injected by the deno.json task. + +const port = Number(Deno.env.get("PORT") ?? 3000); +const isTaskRun = Deno.args.includes("--from-task"); +const greeting = isTaskRun ? "Hello from deno task!" : "Hello from deno!"; + +Deno.serve({ port }, () => + new Response(greeting, { + headers: { "Content-Type": "text/plain" }, + })); + +console.log(`Deno server listening on http://localhost:${port}`); diff --git a/playground/AspireWithDeno/README.md b/playground/AspireWithDeno/README.md new file mode 100644 index 00000000000..643e915a576 --- /dev/null +++ b/playground/AspireWithDeno/README.md @@ -0,0 +1,33 @@ +# AspireWithDeno + +A minimal Aspire **TypeScript AppHost** that hosts a [Deno](https://deno.com/) HTTP server two different ways: + +| Resource | API | Behavior | +|--------------|--------------------------------------------------------------|---------------------------------------------------| +| `denoapp` | `addDenoApp("denoapp", "./DenoFrontend", "main.ts")` | Runs `deno run -A main.ts` directly. | +| `denoscript` | `addDenoApp(...).withRunScript("start")` | Runs `deno task start` against the `start` task. | + +Both resources serve a simple `text/plain` greeting and use `Deno.serve`. + +Deno's built-in OpenTelemetry integration is enabled automatically (via the `OTEL_DENO` +environment variable), so traces, metrics, and logs flow to the Aspire dashboard with no +application-level SDK wiring. + +> **Why `-A`?** Deno runs under a deny-by-default permission model. Aspire injects configuration +> through environment variables (`PORT`, OTLP endpoints, certificate paths) that the app reads with +> `Deno.env` and serves over sockets with `Deno.serve`, both of which require explicit grants. +> `addDenoApp` passes `-A` (allow-all) to keep the developer experience on par with Node and Bun. + +## Prerequisites + +- [Aspire CLI](https://learn.microsoft.com/dotnet/aspire/) on PATH +- [Deno](https://docs.deno.com/runtime/getting_started/installation/) on PATH +- Node.js (used to host the TypeScript AppHost itself) + +## Run + +```bash +aspire run +``` + +The CLI generates the `.aspire/modules/` TypeScript bindings the first time it runs, then starts the AppHost. Open the URL listed for `denoapp` or `denoscript` in the dashboard. Each returns a different greeting so you can confirm which entry path served the response. diff --git a/playground/AspireWithDeno/apphost.mts b/playground/AspireWithDeno/apphost.mts new file mode 100644 index 00000000000..8cd0203bb8c --- /dev/null +++ b/playground/AspireWithDeno/apphost.mts @@ -0,0 +1,24 @@ +// Aspire TypeScript AppHost — exercises the Aspire.Hosting.JavaScript `AddDenoApp` API. +// Run with: aspire run +// Publish with: aspire publish (emits Docker Compose + Dockerfiles using denoland/deno:2) + +import { createBuilder } from './.aspire/modules/aspire.mjs'; + +const builder = await createBuilder(); + +await builder.addDockerComposeEnvironment("compose"); + +// Direct file execution: `deno run -A main.ts` +await builder + .addDenoApp("denoapp", "./DenoFrontend", "main.ts") + .withHttpEndpoint({ env: "PORT" }) + .withExternalHttpEndpoints(); + +// Task execution: `deno task start` (uses the `start` task in deno.json) +await builder + .addDenoApp("denoscript", "./DenoFrontend", "main.ts") + .withRunScript("start") + .withHttpEndpoint({ env: "PORT" }) + .withExternalHttpEndpoints(); + +await builder.build().run(); diff --git a/playground/AspireWithDeno/aspire.config.json b/playground/AspireWithDeno/aspire.config.json new file mode 100644 index 00000000000..c5bdc26f7de --- /dev/null +++ b/playground/AspireWithDeno/aspire.config.json @@ -0,0 +1,10 @@ +{ + "appHost": { + "path": "apphost.mts", + "language": "typescript/nodejs" + }, + "packages": { + "Aspire.Hosting.JavaScript": "", + "Aspire.Hosting.Docker": "" + } +} diff --git a/playground/AspireWithDeno/package.json b/playground/AspireWithDeno/package.json new file mode 100644 index 00000000000..b3c1295a7a6 --- /dev/null +++ b/playground/AspireWithDeno/package.json @@ -0,0 +1,19 @@ +{ + "name": "aspire-with-deno", + "version": "1.0.0", + "description": "Aspire TypeScript AppHost exercising AddDenoApp", + "main": "apphost.mts", + "type": "module", + "private": true, + "scripts": { + "start": "aspire run" + }, + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } +} diff --git a/playground/AspireWithDeno/tsconfig.json b/playground/AspireWithDeno/tsconfig.json new file mode 100644 index 00000000000..5970b48de68 --- /dev/null +++ b/playground/AspireWithDeno/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": [ + "apphost.mts", + ".aspire/modules/**/*.mts" + ], + "exclude": [ + "node_modules", + "DenoFrontend" + ] +} diff --git a/src/Aspire.Hosting.JavaScript/DenoAppResource.cs b/src/Aspire.Hosting.JavaScript/DenoAppResource.cs new file mode 100644 index 00000000000..d18a85773cd --- /dev/null +++ b/src/Aspire.Hosting.JavaScript/DenoAppResource.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.JavaScript; + +/// +/// A resource that represents a Deno application. +/// +/// The name of the resource. +/// The command to execute. +/// The working directory to use for the command. +[AspireExport(ExposeProperties = true)] +public sealed class DenoAppResource(string name, string command, string workingDirectory) + : JavaScriptAppResource(name, command, workingDirectory), IResourceWithServiceDiscovery, IContainerFilesDestinationResource; diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index 9711ab73ace..3b935efebab 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -673,6 +673,237 @@ private static IResourceBuilder WithBunDefaults(this IReso return Task.CompletedTask; }); + // The default Docker image used for AddDenoApp build and runtime stages. + // Pinned to the major version tag to keep generated Dockerfiles deterministic + // while still picking up patch updates. The official image provides a non-root `deno` user. + private const string DefaultDenoImage = "denoland/deno:2"; + + // 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. Unlike the Bun/Node variants this intentionally omits node_modules + // and npm/yarn/pnpm debug logs: Deno caches remote and npm dependencies under DENO_DIR + // (/deno-dir) rather than a project-local node_modules folder. + private const string DefaultDenoBuildContextIgnoreContent = """ + # Generated by Aspire. Author /.dockerignore to override. + .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. + /// + /// This method executes the script directly using deno run -A <script>. 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. + /// + /// 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 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] + 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); + var resource = new DenoAppResource(name, "deno", appDirectory); + + var resourceBuilder = builder.AddResource(resource) + .WithDenoDefaults() + .WithArgs(c => + { + // If the JavaScriptRunScriptAnnotation is present, use that to run the app via `deno task `. + 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 add explicit + // permission flags via WithArgs. + 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; + } + + // 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 and fetches them lazily, so a single build -> runtime copy + // is sufficient. + var baseBuildImage = baseImageAnnotation?.BuildImage ?? DefaultDenoImage; + dockerfileContext.Builder + .From(baseBuildImage, "build") + .EmptyLine() + .WorkDir("/app") + .Copy(".", "."); + + var logger = dockerfileContext.Services.GetService>(); + dockerfileContext.Builder.AddContainerFilesStages(dockerfileContext.Resource, logger); + + var baseRuntimeImage = baseImageAnnotation?.RuntimeImage ?? DefaultDenoImage; + dockerfileContext.Builder + .From(baseRuntimeImage, "runtime") + .EmptyLine() + .WorkDir("/app") + .CopyFrom("build", "/app", "/app") + .AddContainerFiles(dockerfileContext.Resource, "/app", logger) + .EmptyLine() + // The official denoland/deno images provide a non-root `deno` user. + // See https://github.com/denoland/deno_docker + .User("deno") + .EmptyLine() + .Entrypoint([resource.Command, "run", "-A", 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((_, _) => + { + // 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 => + builder.WithOtlpExporter() + .WithRequiredCommand("deno", "https://docs.deno.com/runtime/getting_started/installation/") + // Deno has first-class, built-in OpenTelemetry support. Setting OTEL_DENO=true enables automatic export + // of traces, metrics, and logs to the OTLP endpoint configured by WithOtlpExporter, with no + // application-level SDK required. Unlike the Node/Bun variants (which need an in-process OpenTelemetry + // SDK), this is the headline Deno hosting win. The instrumentation is resilient to an unavailable + // collector, so it is safe to enable unconditionally. + // See https://docs.deno.com/runtime/fundamentals/open_telemetry/ + .WithEnvironment("OTEL_DENO", "true") + .WithCertificateTrustConfiguration((ctx) => + { + if (ctx.Scope == CertificateTrustScope.Append) + { + // DENO_CERT loads an additional PEM certificate file into Deno's trust store on top of its + // built-in Mozilla root store, which is exactly the append semantics Aspire needs to make its + // injected dev certificate trusted. See + // https://docs.deno.com/runtime/reference/env_variables/#special-environment-variables + ctx.EnvironmentVariables["DENO_CERT"] = ctx.CertificateBundlePath; + } + else + { + // Override/System scopes route TLS verification through the operating system trust store via + // DENO_TLS_CA_STORE=system (the default is the bundled "mozilla" store). See + // https://docs.deno.com/runtime/reference/env_variables/#special-environment-variables + ctx.EnvironmentVariables["DENO_TLS_CA_STORE"] = "system"; + } + + return Task.CompletedTask; + }); + /// /// Adds a JavaScript application resource to the distributed application using the specified app directory and /// run script. @@ -1766,6 +1997,97 @@ public static IResourceBuilder WithBun(this IResourceBuild 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) 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] + 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", cacheMount: "/deno-dir") + { + // Deno's task runner forwards script arguments without requiring the `--` separator. + CommandSeparator = null, + ResolvePackageScriptRuntimeImage = buildImage => buildImage, + }; + + if (packageFilesSourcePattern.Length > 0) + { + packageManager.PackageFilesPatterns.Add(new CopyFilePattern(packageFilesSourcePattern, "./")); + } + + resource + .WithAnnotation(packageManager) + .WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])) + .WithRequiredCommand("deno", "https://docs.deno.com/runtime/getting_started/installation/"); + + 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); + } + + return resource; + } + private static string[] GetDefaultBunInstallArgs(IResourceBuilder resource, bool hasBunLock) => resource.ApplicationBuilder.ExecutionContext.IsPublishMode && hasBunLock ? ["--frozen-lockfile"] @@ -1998,6 +2320,12 @@ internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder $"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 => { diff --git a/src/Aspire.Hosting.JavaScript/api/Aspire.Hosting.JavaScript.ats.txt b/src/Aspire.Hosting.JavaScript/api/Aspire.Hosting.JavaScript.ats.txt index d1bd708f3ec..ce7aefef62d 100644 --- a/src/Aspire.Hosting.JavaScript/api/Aspire.Hosting.JavaScript.ats.txt +++ b/src/Aspire.Hosting.JavaScript/api/Aspire.Hosting.JavaScript.ats.txt @@ -3,6 +3,7 @@ # Handle Types Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.BunAppResource [ExposeProperties] +Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.DenoAppResource [ExposeProperties] Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.JavaScriptAppResource [ExposeProperties] Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.NextJsAppResource [ExposeProperties] Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.NodeAppResource [ExposeProperties] @@ -10,6 +11,7 @@ Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.ViteAppResource [ExposePrope # Capabilities Aspire.Hosting.JavaScript/addBunApp(name: string, appDirectory: string, scriptPath: string) -> Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.BunAppResource +Aspire.Hosting.JavaScript/addDenoApp(name: string, appDirectory: string, scriptPath: string) -> Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.DenoAppResource Aspire.Hosting.JavaScript/addJavaScriptApp(name: string, appDirectory: string, runScriptName?: string) -> Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.JavaScriptAppResource Aspire.Hosting.JavaScript/addNextJsApp(name: string, appDirectory: string, runScriptName?: string) -> Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.NextJsAppResource Aspire.Hosting.JavaScript/addNodeApp(name: string, appDirectory: string, scriptPath: string) -> Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.NodeAppResource @@ -21,6 +23,7 @@ Aspire.Hosting.JavaScript/publishAsStaticWebsite(apiPath?: string, apiTarget?: A Aspire.Hosting.JavaScript/withBrowserDebugger(browser?: string) -> Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.JavaScriptAppResource Aspire.Hosting.JavaScript/withBuildScript(scriptName: string, args?: string[]) -> Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.JavaScriptAppResource Aspire.Hosting.JavaScript/withBun(install?: boolean, installArgs?: string[]) -> Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.JavaScriptAppResource +Aspire.Hosting.JavaScript/withDeno(install?: boolean, installArgs?: string[]) -> Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.JavaScriptAppResource Aspire.Hosting.JavaScript/withNpm(install?: boolean, installCommand?: string, installArgs?: string[]) -> Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.JavaScriptAppResource Aspire.Hosting.JavaScript/withPnpm(install?: boolean, installArgs?: string[]) -> Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.JavaScriptAppResource Aspire.Hosting.JavaScript/withRunScript(scriptName: string, args?: string[]) -> Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.JavaScriptAppResource diff --git a/src/Aspire.Hosting.JavaScript/api/Aspire.Hosting.JavaScript.cs b/src/Aspire.Hosting.JavaScript/api/Aspire.Hosting.JavaScript.cs index 62369ff88ca..6be1a84333c 100644 --- a/src/Aspire.Hosting.JavaScript/api/Aspire.Hosting.JavaScript.cs +++ b/src/Aspire.Hosting.JavaScript/api/Aspire.Hosting.JavaScript.cs @@ -13,6 +13,9 @@ public static partial class JavaScriptHostingExtensions [AspireExport] public static ApplicationModel.IResourceBuilder AddBunApp(this IDistributedApplicationBuilder builder, string name, string appDirectory, string scriptPath) { throw null; } + [AspireExport] + public static ApplicationModel.IResourceBuilder AddDenoApp(this IDistributedApplicationBuilder builder, string name, string appDirectory, string scriptPath) { throw null; } + [AspireExport] public static ApplicationModel.IResourceBuilder AddJavaScriptApp(this IDistributedApplicationBuilder builder, string name, string appDirectory, string runScriptName = "dev") { throw null; } @@ -63,6 +66,10 @@ public static ApplicationModel.IResourceBuilder WithBuildScript WithBun(this ApplicationModel.IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScript.JavaScriptAppResource { throw null; } + [AspireExport] + public static ApplicationModel.IResourceBuilder WithDeno(this ApplicationModel.IResourceBuilder resource, bool install = false, string[]? installArgs = null) + where TResource : JavaScript.JavaScriptAppResource { throw null; } + [AspireExport] public static ApplicationModel.IResourceBuilder WithNpm(this ApplicationModel.IResourceBuilder resource, bool install = true, string? installCommand = null, string[]? installArgs = null) where TResource : JavaScript.JavaScriptAppResource { throw null; } @@ -96,6 +103,12 @@ public sealed partial record CopyFilePattern(string Source, string Destination) { } + [AspireExport(ExposeProperties = true)] + public sealed partial class DenoAppResource : JavaScriptAppResource, IResourceWithServiceDiscovery, ApplicationModel.IResourceWithEndpoints, ApplicationModel.IResource, ApplicationModel.IContainerFilesDestinationResource + { + public DenoAppResource(string name, string command, string workingDirectory) : base(default!, default!, default!) { } + } + [AspireExport(ExposeProperties = true)] public partial class JavaScriptAppResource : ApplicationModel.ExecutableResource, IResourceWithServiceDiscovery, ApplicationModel.IResourceWithEndpoints, ApplicationModel.IResource, IResourceWithContainerFiles { diff --git a/tests/Aspire.Hosting.JavaScript.Tests/AddDenoAppTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/AddDenoAppTests.cs new file mode 100644 index 00000000000..c499c0b6c62 --- /dev/null +++ b/tests/Aspire.Hosting.JavaScript.Tests/AddDenoAppTests.cs @@ -0,0 +1,423 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Dcp.Model; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.JavaScript.Tests; + +public class AddDenoAppTests +{ + [Fact] + public async Task VerifyManifest() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var workingDirectory = AppContext.BaseDirectory; + var denoApp = builder.AddDenoApp("denoapp", workingDirectory, "main.ts") + .WithHttpEndpoint(port: 5033, env: "PORT"); + var manifest = await ManifestUtils.GetManifest(denoApp.Resource); + + await Verify(manifest.ToString()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task VerifyDockerfile(bool includePackageJson) + { + 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 (includePackageJson) + { + File.WriteAllText(Path.Combine(appDir, "package.json"), "{}"); + } + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts"); + + await ManifestUtils.GetManifest(denoApp.Resource, tempDir.Path); + + var dockerfilePath = Path.Combine(tempDir.Path, "js.Dockerfile"); + var dockerfileContents = File.ReadAllText(dockerfilePath); + await Verify(dockerfileContents); + + var dockerBuildAnnotation = denoApp.Resource.Annotations.OfType().Single(); + Assert.True(dockerBuildAnnotation.HasEntrypoint); + + Assert.Empty(denoApp.Resource.Annotations.OfType()); + } + + [Fact] + public async Task VerifyDockerfileWithCustomBaseImage() + { + 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); + File.WriteAllText(Path.Combine(appDir, "package.json"), "{}"); + + var customBuildImage = "denoland/deno:2.1-alpine"; + var customRuntimeImage = "denoland/deno:2.1-distroless"; + var denoApp = builder.AddDenoApp("js", appDir, "main.ts") + .WithDockerfileBaseImage(customBuildImage, customRuntimeImage); + + await ManifestUtils.GetManifest(denoApp.Resource, tempDir.Path); + + await Verify(File.ReadAllText(Path.Combine(tempDir.Path, "js.Dockerfile"))); + } + + [Fact] + public async Task VerifyDockerfileEmitsPerDockerfileDockerignore() + { + 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); + File.WriteAllText(Path.Combine(appDir, "package.json"), "{}"); + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts"); + + await ManifestUtils.GetManifest(denoApp.Resource, tempDir.Path); + + // The default .dockerignore should be emitted alongside the published Dockerfile using + // BuildKit's per-Dockerfile convention (.dockerignore), not into the + // user's source tree. + var perDockerfileIgnorePath = Path.Combine(tempDir.Path, "js.Dockerfile.dockerignore"); + Assert.True(File.Exists(perDockerfileIgnorePath), $"Expected per-Dockerfile dockerignore at {perDockerfileIgnorePath}"); + var ignoreContents = File.ReadAllText(perDockerfileIgnorePath); + await Verify(ignoreContents); + + // The user's source tree must not be polluted with a generated .dockerignore. + Assert.False(File.Exists(Path.Combine(appDir, ".dockerignore")), "Aspire should not write a .dockerignore into the user's source tree."); + + // The annotation should carry the default content so it can be inspected/overridden by users. + // Unlike the Bun/Node variants, the Deno ignore intentionally does not list node_modules + // because Deno caches dependencies under DENO_DIR rather than a project-local folder. + var dockerBuildAnnotation = denoApp.Resource.Annotations.OfType().Single(); + Assert.NotNull(dockerBuildAnnotation.BuildContextIgnoreContent); + Assert.DoesNotContain("node_modules", dockerBuildAnnotation.BuildContextIgnoreContent!); + } + + [Fact] + public void AddDenoApp_DoesNotAddDenoPackageManagerWhenNoManifest() + { + using var tempDir = new TestTempDirectory(); + File.WriteAllText(Path.Combine(tempDir.Path, "main.ts"), "console.log('hi');"); + + var builder = DistributedApplication.CreateBuilder(); + + builder.AddDenoApp("denoapp", tempDir.Path, "main.ts"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var denoResource = Assert.Single(appModel.Resources.OfType()); + + // No package.json/deno.json: don't auto-configure Deno as a package manager and don't add an installer. + Assert.False(denoResource.TryGetLastAnnotation(out _)); + Assert.False(denoResource.TryGetLastAnnotation(out _)); + Assert.Empty(appModel.Resources.OfType()); + } + + [Fact] + public void AddDenoApp_AddsDenoPackageManagerWhenPackageJsonExists() + { + using var tempDir = new TestTempDirectory(); + File.WriteAllText(Path.Combine(tempDir.Path, "package.json"), "{}"); + + var builder = DistributedApplication.CreateBuilder(); + + builder.AddDenoApp("denoapp", tempDir.Path, "main.ts"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var denoResource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(denoResource.TryGetLastAnnotation(out var packageManager)); + Assert.Equal("deno", packageManager.ExecutableName); + Assert.Equal("task", packageManager.ScriptCommand); + + Assert.True(denoResource.TryGetLastAnnotation(out var installAnnotation)); + Assert.Equal(["install"], installAnnotation.Args); + + // Deno caches dependencies on first run, so no installer resource is created by default. + Assert.Empty(appModel.Resources.OfType()); + } + + [Fact] + public void AddDenoApp_AddsDenoPackageManagerWhenDenoJsonExists() + { + using var tempDir = new TestTempDirectory(); + File.WriteAllText(Path.Combine(tempDir.Path, "deno.json"), "{}"); + + var builder = DistributedApplication.CreateBuilder(); + + builder.AddDenoApp("denoapp", tempDir.Path, "main.ts"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var denoResource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(denoResource.TryGetLastAnnotation(out var packageManager)); + Assert.Equal("deno", packageManager.ExecutableName); + Assert.Equal("task", packageManager.ScriptCommand); + Assert.Empty(appModel.Resources.OfType()); + } + + [Fact] + public async Task AddDenoApp_DirectFile_ProducesRunDashAArgs() + { + using var tempDir = new TestTempDirectory(); + File.WriteAllText(Path.Combine(tempDir.Path, "main.ts"), "console.log('hi');"); + + var builder = DistributedApplication.CreateBuilder(); + builder.AddDenoApp("denoapp", tempDir.Path, "main.ts"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var denoResource = Assert.Single(appModel.Resources.OfType()); + + var args = await ArgumentEvaluator.GetArgumentListAsync(denoResource); + + // Deno requires the `run` subcommand and, unlike Node/Bun, a permission grant (`-A`) to read + // env vars and open sockets. + Assert.Collection(args, + arg => Assert.Equal("run", arg), + arg => Assert.Equal("-A", arg), + arg => Assert.Equal("main.ts", arg)); + } + + [Fact] + public async Task WithRunScript_SetsCustomTaskCommand() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddDenoApp("denoapp", ".", "main.ts") + .WithDeno() + .WithRunScript("start", ["--my-arg1"]); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var denoResource = Assert.Single(appModel.Resources.OfType()); + + var args = await ArgumentEvaluator.GetArgumentListAsync(denoResource); + + // Deno runs package scripts through its task runner (`deno task `). + Assert.Collection(args, + arg => Assert.Equal("task", arg), + arg => Assert.Equal("start", arg), + arg => Assert.Equal("--my-arg1", arg)); + } + + [Fact] + public void AddDenoApp_UsesDenoCommand() + { + using var tempDir = new TestTempDirectory(); + + var builder = DistributedApplication.CreateBuilder(); + var denoApp = builder.AddDenoApp("denoapp", tempDir.Path, "main.ts"); + + Assert.Equal("deno", denoApp.Resource.Command); + } + + [Fact] + public void AddDenoApp_ThrowsForNullBuilder() + { + Assert.Throws(() => + JavaScriptHostingExtensions.AddDenoApp(null!, "denoapp", ".", "main.ts")); + } + + [Fact] + public void AddDenoApp_ThrowsForEmptyName() + { + var builder = DistributedApplication.CreateBuilder(); + Assert.Throws(() => builder.AddDenoApp("", ".", "main.ts")); + } + + [Fact] + public void AddDenoApp_ThrowsForEmptyScriptPath() + { + var builder = DistributedApplication.CreateBuilder(); + Assert.Throws(() => builder.AddDenoApp("denoapp", ".", "")); + } + + [Fact] + public async Task AddDenoApp_ConfiguresCertificateTrustForAppendScope() + { + var builder = DistributedApplication.CreateBuilder(); + var denoApp = builder.AddDenoApp("denoapp", ".", "main.ts"); + + Assert.True(denoApp.Resource.TryGetLastAnnotation(out var annotation)); + + var envVars = new Dictionary(); + var bundle = ReferenceExpression.Create($"/etc/ssl/aspire/bundle.crt"); + var dirs = ReferenceExpression.Create($"/etc/ssl/aspire/certs"); + var ctx = new CertificateTrustConfigurationCallbackAnnotationContext + { + ExecutionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + Resource = denoApp.Resource, + Arguments = [], + EnvironmentVariables = envVars, + CertificateBundlePath = bundle, + CertificateDirectoriesPath = dirs, + Scope = CertificateTrustScope.Append, + CancellationToken = default, + }; + + await annotation.Callback(ctx); + + // Deno loads an additional PEM certificate via DENO_CERT on top of its bundled Mozilla store. + Assert.Same(bundle, envVars["DENO_CERT"]); + } + + [Fact] + public async Task AddDenoApp_ConfiguresCertificateTrustForOverrideScope() + { + var builder = DistributedApplication.CreateBuilder(); + var denoApp = builder.AddDenoApp("denoapp", ".", "main.ts"); + + Assert.True(denoApp.Resource.TryGetLastAnnotation(out var annotation)); + + var envVars = new Dictionary(); + var ctx = new CertificateTrustConfigurationCallbackAnnotationContext + { + ExecutionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + Resource = denoApp.Resource, + Arguments = [], + EnvironmentVariables = envVars, + CertificateBundlePath = ReferenceExpression.Create($"/etc/ssl/aspire/bundle.crt"), + CertificateDirectoriesPath = ReferenceExpression.Create($"/etc/ssl/aspire/certs"), + Scope = CertificateTrustScope.Override, + CancellationToken = default, + }; + + await annotation.Callback(ctx); + + // Override/System scopes route TLS verification through the OS trust store via DENO_TLS_CA_STORE=system. + Assert.Equal("system", envVars["DENO_TLS_CA_STORE"]); + } + + [Fact] + public async Task AddDenoApp_EnablesNativeOpenTelemetry() + { + var builder = DistributedApplication.CreateBuilder(); + var denoApp = builder.AddDenoApp("denoapp", ".", "main.ts"); + + var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(denoApp.Resource, DistributedApplicationOperation.Run); + + // Deno's built-in OpenTelemetry integration is enabled with a single environment variable. + Assert.Equal("true", env["OTEL_DENO"]); + } + +#pragma warning disable ASPIREEXTENSION001 // Type is for evaluation purposes only + + [Fact] + public void DenoApp_WithVSCodeDebugging_AddsSupportsDebuggingAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var denoApp = builder.AddDenoApp("denoapp", tempDir.Path, "main.ts"); + + var annotation = denoApp.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + Assert.Equal("deno", annotation.LaunchConfigurationType); + } + + [Fact] + public void DenoApp_WithVSCodeDebugging_DoesNotAddAnnotationInPublishMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + using var tempDir = new TestTempDirectory(); + + var denoApp = builder.AddDenoApp("denoapp", tempDir.Path, "main.ts"); + + var annotation = denoApp.Resource.Annotations.OfType().SingleOrDefault(); + Assert.Null(annotation); + } + + [Fact] + public void DenoApp_WithRunScript_AddsSupportsDebuggingAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var denoApp = builder.AddDenoApp("denoapp", tempDir.Path, "main.ts") + .WithRunScript("dev"); + + var annotation = denoApp.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + Assert.Equal("deno", annotation.LaunchConfigurationType); + } + + [Fact] + public void DenoApp_DirectFile_ProducesDenoRuntimeExecutable() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var denoApp = builder.AddDenoApp("denoapp", tempDir.Path, "main.ts"); + + var launchConfig = InvokeLaunchConfigurationAnnotator(denoApp.Resource); + + Assert.Equal("deno", launchConfig.Type); + Assert.Equal("deno", launchConfig.RuntimeExecutable); + Assert.Equal("direct", launchConfig.LaunchMethod); + Assert.Equal(Path.GetFullPath("main.ts", tempDir.Path), launchConfig.ScriptPath); + } + + [Fact] + public void DenoApp_WithRunScriptAndPackageManager_ProducesDenoRuntimeExecutable() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + // AddDenoApp automatically calls WithDeno() when a deno.json exists, which makes the run-script a + // package-manager invocation (deno task dev). + File.WriteAllText(Path.Combine(tempDir.Path, "deno.json"), "{}"); + + var denoApp = builder.AddDenoApp("denoapp", tempDir.Path, "main.ts") + .WithRunScript("dev"); + + var launchConfig = InvokeLaunchConfigurationAnnotator(denoApp.Resource); + + Assert.Equal("deno", launchConfig.Type); + Assert.Equal("deno", launchConfig.RuntimeExecutable); + Assert.Equal("package-manager", launchConfig.LaunchMethod); + } + + private static JavaScriptLaunchConfiguration InvokeLaunchConfigurationAnnotator(IResource resource) + { + Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging)); + + var exe = Executable.Create("test", "deno"); + supportsDebugging.LaunchConfigurationAnnotator(exe, ExecutableLaunchMode.Debug); + + Assert.True(exe.TryGetAnnotationAsObjectList( + Executable.LaunchConfigurationsAnnotation, + out var launchConfigs)); + return Assert.Single(launchConfigs); + } + +#pragma warning restore ASPIREEXTENSION001 // Type is for evaluation purposes only +} diff --git a/tests/Aspire.Hosting.JavaScript.Tests/DenoAppFixture.cs b/tests/Aspire.Hosting.JavaScript.Tests/DenoAppFixture.cs new file mode 100644 index 00000000000..96902a368b7 --- /dev/null +++ b/tests/Aspire.Hosting.JavaScript.Tests/DenoAppFixture.cs @@ -0,0 +1,144 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Testing; +using Aspire.Hosting.Utils; +using Xunit.Sdk; + +namespace Aspire.Hosting.JavaScript.Tests; + +/// +/// Test fixture that boots an Aspire application with two instances: +/// one running a script file directly (deno run -A main.ts) and one via a package-manager task +/// (deno task start). +/// +public class DenoAppFixture(IMessageSink diagnosticMessageSink) : IAsyncLifetime +{ + private IDistributedApplicationTestingBuilder? _builder; + private DistributedApplication? _app; + private string? _denoAppPath; + + public DistributedApplication App => _app ?? throw new InvalidOperationException("DistributedApplication is not initialized."); + + public IResourceBuilder? DenoAppBuilder { get; private set; } + public IResourceBuilder? DenoScriptBuilder { get; private set; } + + public async ValueTask InitializeAsync() + { + _builder = TestDistributedApplicationBuilder.Create() + .WithTestAndResourceLogging(new TestOutputWrapper(diagnosticMessageSink)); + + _denoAppPath = CreateDenoApp(); + + DenoAppBuilder = _builder.AddDenoApp("denoapp", _denoAppPath, "main.ts") + .WithHttpEndpoint(env: "PORT") + .WithHttpHealthCheck("/", endpointName: "http"); + + DenoScriptBuilder = _builder.AddDenoApp("denoscript", _denoAppPath, "main.ts") + .WithRunScript("start") + .WithHttpEndpoint(env: "PORT") + .WithHttpHealthCheck("/", endpointName: "http"); + + _app = _builder.Build(); + + using var startCts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + await _app.StartAsync(startCts.Token); + + using var readinessCts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + await WaitReadyStateAsync(readinessCts.Token); + } + + public async ValueTask DisposeAsync() + { + _builder?.Dispose(); + + if (_app is not null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + + if (_denoAppPath is not null) + { + try + { + Directory.Delete(_denoAppPath, recursive: true); + } + catch + { + // Don't fail the test if we can't clean up the temporary folder + } + } + } + + private static string CreateDenoApp() + { + var tempDir = Directory.CreateTempSubdirectory("aspire-deno-tests").FullName; + + // Minimal Deno HTTP server. Distinguishes between direct (`deno run -A main.ts`) and + // task (`deno task start`) invocations via the `--from-task` argument injected by the + // deno.json task definition below. + File.WriteAllText(Path.Combine(tempDir, "main.ts"), + """ + const port = Number(Deno.env.get("PORT") ?? 3000); + const isTaskRun = Deno.args.includes("--from-task"); + const greeting = isTaskRun ? "Hello from deno task!" : "Hello from deno!"; + + Deno.serve({ port }, () => + new Response(greeting, { + headers: { "Content-Type": "text/plain" }, + })); + + console.log(`Deno server listening on ${port}`); + """); + + // deno.json defines the `start` task. AddDenoApp auto-detects deno.json and configures Deno as + // the package manager, so `.WithRunScript("start")` runs `deno task start`. + File.WriteAllText(Path.Combine(tempDir, "deno.json"), + """ + { + "tasks": { + "start": "deno run -A main.ts --from-task" + } + } + """); + + return tempDir; + } + + private async Task WaitReadyStateAsync(CancellationToken cancellationToken) + { + // Wait for each resource in parallel — separate timeouts would compound startup time + // and either resource being slow shouldn't starve the other. + await Task.WhenAll( + App.ResourceNotifications.WaitForResourceHealthyAsync(DenoAppBuilder!.Resource.Name, cancellationToken), + App.ResourceNotifications.WaitForResourceHealthyAsync(DenoScriptBuilder!.Resource.Name, cancellationToken)); + } + + private sealed class TestOutputWrapper(IMessageSink messageSink) : ITestOutputHelper + { + public string Output => string.Empty; + + public void Write(string message) + { + messageSink.OnMessage(new DiagnosticMessage(message)); + } + + public void Write(string format, params object[] args) + { + messageSink.OnMessage(new DiagnosticMessage(string.Format(CultureInfo.CurrentCulture, format, args))); + } + + public void WriteLine(string message) + { + messageSink.OnMessage(new DiagnosticMessage(message)); + } + + public void WriteLine(string format, params object[] args) + { + messageSink.OnMessage(new DiagnosticMessage(string.Format(CultureInfo.CurrentCulture, format, args))); + } + } +} diff --git a/tests/Aspire.Hosting.JavaScript.Tests/DenoFunctionalTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/DenoFunctionalTests.cs new file mode 100644 index 00000000000..9b90f4e21b5 --- /dev/null +++ b/tests/Aspire.Hosting.JavaScript.Tests/DenoFunctionalTests.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.TestUtilities; +using Aspire.Hosting.Testing; +using Microsoft.AspNetCore.InternalTesting; + +namespace Aspire.Hosting.JavaScript.Tests; + +[RequiresTools(["deno"])] +public class DenoFunctionalTests : IClassFixture +{ + private readonly DenoAppFixture _denoFixture; + + public DenoFunctionalTests(DenoAppFixture denoFixture) + { + _denoFixture = denoFixture; + } + + [Fact] + public async Task VerifyDenoAppDirectExecutionWorks() + { + using var cts = new CancellationTokenSource(TestConstants.LongTimeoutDuration); + using var denoClient = _denoFixture.App.CreateHttpClient(_denoFixture.DenoAppBuilder!.Resource.Name, "http"); + var response = await denoClient.GetStringAsync("/", cts.Token); + + Assert.Equal("Hello from deno!", response); + } + + [Fact] + public async Task VerifyDenoAppTaskScriptWorks() + { + using var cts = new CancellationTokenSource(TestConstants.LongTimeoutDuration); + using var denoClient = _denoFixture.App.CreateHttpClient(_denoFixture.DenoScriptBuilder!.Resource.Name, "http"); + var response = await denoClient.GetStringAsync("/", cts.Token); + + Assert.Equal("Hello from deno task!", response); + } +} diff --git a/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfileEmitsPerDockerfileDockerignore.verified.txt b/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfileEmitsPerDockerfileDockerignore.verified.txt new file mode 100644 index 00000000000..600c1588bd0 --- /dev/null +++ b/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfileEmitsPerDockerfileDockerignore.verified.txt @@ -0,0 +1,13 @@ +# Generated by Aspire. Author /.dockerignore to override. +.git +.gitignore +.DS_Store +.env +.env.* +.aspire +aspire-output +Dockerfile +Dockerfile.* +*.Dockerfile.dockerignore +.dockerignore +*.tsbuildinfo diff --git a/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfileWithCustomBaseImage.verified.txt b/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfileWithCustomBaseImage.verified.txt new file mode 100644 index 00000000000..ca7e109619e --- /dev/null +++ b/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfileWithCustomBaseImage.verified.txt @@ -0,0 +1,13 @@ +FROM denoland/deno:2.1-alpine AS build + +WORKDIR /app +COPY . . + +FROM denoland/deno:2.1-distroless AS runtime + +WORKDIR /app +COPY --from=build /app /app + +USER deno + +ENTRYPOINT ["deno","run","-A","main.ts"] diff --git a/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfile_includePackageJson=False.verified.txt b/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfile_includePackageJson=False.verified.txt new file mode 100644 index 00000000000..f5ccc26c344 --- /dev/null +++ b/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfile_includePackageJson=False.verified.txt @@ -0,0 +1,13 @@ +FROM denoland/deno:2 AS build + +WORKDIR /app +COPY . . + +FROM denoland/deno:2 AS runtime + +WORKDIR /app +COPY --from=build /app /app + +USER deno + +ENTRYPOINT ["deno","run","-A","main.ts"] diff --git a/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfile_includePackageJson=True.verified.txt b/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfile_includePackageJson=True.verified.txt new file mode 100644 index 00000000000..f5ccc26c344 --- /dev/null +++ b/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyDockerfile_includePackageJson=True.verified.txt @@ -0,0 +1,13 @@ +FROM denoland/deno:2 AS build + +WORKDIR /app +COPY . . + +FROM denoland/deno:2 AS runtime + +WORKDIR /app +COPY --from=build /app /app + +USER deno + +ENTRYPOINT ["deno","run","-A","main.ts"] diff --git a/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyManifest.verified.txt b/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyManifest.verified.txt new file mode 100644 index 00000000000..e7a75afaf69 --- /dev/null +++ b/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddDenoAppTests.VerifyManifest.verified.txt @@ -0,0 +1,23 @@ +{ + "type": "executable.v0", + "workingDirectory": ".", + "command": "deno", + "args": [ + "run", + "-A", + "main.ts" + ], + "env": { + "OTEL_DENO": "true", + "PORT": "{denoapp.bindings.http.targetPort}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "port": 5033, + "targetPort": 8000 + } + } +} \ No newline at end of file From 6f6a2f0792193ca391665bad8448fbc2f06d3cc2 Mon Sep 17 00:00:00 2001 From: Rickylabs Date: Fri, 3 Jul 2026 03:36:44 +0200 Subject: [PATCH 02/83] feat(hosting-js): full Deno flag surface for AddDenoApp AddDenoApp previously hardcoded `deno run -A