diff --git a/.github/actions/setup-deno/action.yml b/.github/actions/setup-deno/action.yml new file mode 100644 index 00000000000..a8cd70ebb2f --- /dev/null +++ b/.github/actions/setup-deno/action.yml @@ -0,0 +1,85 @@ +name: 'Setup Deno' +description: 'Installs the pinned Deno version used by Aspire tests' + +runs: + using: 'composite' + steps: + - name: Install Deno (Linux/macOS) + if: runner.os != 'Windows' + shell: bash + run: | + case "${{ runner.os }}-${{ runner.arch }}" in + Linux-X64) + TARGET="x86_64-unknown-linux-gnu" + SHA256="96c9c73fc05ea57603bc28d8071d41e861bd40bcf2a9f849dd5969ed1a2c8498" + ;; + Linux-ARM64) + TARGET="aarch64-unknown-linux-gnu" + SHA256="a0f266eb189af2f27db0c6dcf9eb2f68ba2e8bbff1442c969ace398463f8b2bf" + ;; + macOS-X64) + TARGET="x86_64-apple-darwin" + SHA256="04f71604d738ef2a3b0c08d00743b1a6580fd65d0dab604da2f57f30f4c74b55" + ;; + macOS-ARM64) + TARGET="aarch64-apple-darwin" + SHA256="2d11cf0505d4600a4492de8d07456a7a5e7eedebf68bdcbcb9092f520fcde0f1" + ;; + *) + echo "Unsupported runner: ${{ runner.os }}-${{ runner.arch }}" >&2 + exit 1 + ;; + esac + + INSTALL_DIR="${RUNNER_TEMP}/deno-2.9.0" + ARCHIVE="${RUNNER_TEMP}/deno-${TARGET}.zip" + curl --fail --location --retry 5 --output "$ARCHIVE" \ + "https://github.com/denoland/deno/releases/download/v2.9.0/deno-${TARGET}.zip" + + if [ "${{ runner.os }}" = "macOS" ]; then + echo "${SHA256} ${ARCHIVE}" | shasum --algorithm 256 --check + else + echo "${SHA256} ${ARCHIVE}" | sha256sum --check + fi + + mkdir -p "$INSTALL_DIR" + unzip -q "$ARCHIVE" -d "$INSTALL_DIR" + chmod +x "$INSTALL_DIR/deno" + echo "$INSTALL_DIR" >> "$GITHUB_PATH" + "$INSTALL_DIR/deno" --version + + - name: Install Deno (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $target, $sha256 = switch ("${{ runner.arch }}") { + "X64" { + "x86_64-pc-windows-msvc" + "37e3a8e5f4ee360d08bbeec9ee07fdcaa9dcd1a39d4aeaac5807354aec557451" + } + "ARM64" { + "aarch64-pc-windows-msvc" + "de3d31811dd583d6b15746db1463bebc204118aa0e8b3fe2b5ecc488ed198cbf" + } + default { + Write-Error "Unsupported runner: Windows-${{ runner.arch }}" + exit 1 + } + } + + $installDir = Join-Path $env:RUNNER_TEMP "deno-2.9.0" + $archive = Join-Path $env:RUNNER_TEMP "deno-$target.zip" + Invoke-WebRequest ` + -Uri "https://github.com/denoland/deno/releases/download/v2.9.0/deno-$target.zip" ` + -OutFile $archive + + $actualHash = (Get-FileHash -Algorithm SHA256 $archive).Hash + if ($actualHash -ne $sha256) { + Write-Error "Deno archive checksum mismatch. Expected $sha256, got $actualHash." + exit 1 + } + + New-Item -ItemType Directory -Force -Path $installDir | Out-Null + Expand-Archive -Path $archive -DestinationPath $installDir -Force + $installDir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + & (Join-Path $installDir "deno.exe") --version diff --git a/.github/workflows/extension-e2e-tests.yml b/.github/workflows/extension-e2e-tests.yml index 9123b301f7d..8defb83da56 100644 --- a/.github/workflows/extension-e2e-tests.yml +++ b/.github/workflows/extension-e2e-tests.yml @@ -396,6 +396,15 @@ jobs: cliBinary: aspire useXvfb: true installAzureFunctions: true + - name: Linux + shardName: deno-debugger + spec: out/test-e2e/test-e2e/denoDebugger.e2e.test.js + runner: ubuntu-latest + rid: linux-x64 + archivePattern: aspire-cli-linux-x64*.tar.gz + cliBinary: aspire + useXvfb: true + installDeno: true defaults: run: shell: pwsh @@ -453,6 +462,10 @@ jobs: with: global-json-file: global.json + - name: Setup Deno + if: ${{ matrix.installDeno }} + uses: ./.github/actions/setup-deno + - name: Download VSIX uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/polyglot-validation/test-python-playground.sh b/.github/workflows/polyglot-validation/test-python-playground.sh index 93cb235b10c..6444c64a143 100755 --- a/.github/workflows/polyglot-validation/test-python-playground.sh +++ b/.github/workflows/polyglot-validation/test-python-playground.sh @@ -2,7 +2,8 @@ # Polyglot SDK Validation - Python validation AppHosts # Iterates all Python validation AppHosts under tests/PolyglotAppHosts/*/Python, # runs 'aspire restore --apphost' to regenerate the per-integration .aspire/modules/ SDK, and -# type-checks each AppHost against the generated SDK API surface. +# compiles and type-checks each AppHost against the generated Python modules. The JavaScript +# fixture also executes its Deno calls without starting an AppHost or external services. set -euo pipefail echo "=== Python Validation AppHost Codegen Validation ===" @@ -165,6 +166,56 @@ INNERPY continue fi + if [ "$integration_name" = "Aspire.Hosting.JavaScript" ]; then + echo " -> generated Deno SDK execution..." + if ! PYTHONPATH="$PWD/.aspire/modules${PYTHONPATH:+:$PYTHONPATH}" python3 - <<'INNERPY' +import apphost +import aspire_app + + +# Syntax compilation does not resolve Python attributes. This client lets the fixture call the +# generated builder and Deno resource classes while keeping every capability invocation in-process. +class ValidationClient: + def __init__(self): + self.next_handle = 0 + + def create_handle(self, type_id): + self.next_handle += 1 + return aspire_app.Handle({ + "$handle": str(self.next_handle), + "$type": type_id, + }) + + def invoke_capability(self, capability_id, args, kwargs=None): + if capability_id == "Aspire.Hosting/createBuilder": + return self.create_handle("Aspire.Hosting/IDistributedApplicationBuilder") + if capability_id == "Aspire.Hosting.JavaScript/addDenoApp": + handle = self.create_handle("Aspire.Hosting.JavaScript/DenoAppResource") + return aspire_app.DenoAppResource(handle, self) + + return next( + value for value in args.values() + if isinstance(value, aspire_app.Handle) + ) + + def disconnect(self): + pass + + +client = ValidationClient() +options = aspire_app.CreateBuilderOptions() +with aspire_app.DistributedApplicationBuilder(client, options) as builder: + apphost.add_deno_app(builder) +INNERPY + then + echo " ERROR: generated Deno SDK execution failed for $integration_name" + FAILED+=("$integration_name (generated Deno SDK execution)") + echo "" + restore_channel_settings + continue + fi + fi + restore_channel_settings echo " OK: $integration_name passed" PASSED+=("$integration_name") diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5ebce8bead1..775c524b2d6 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -99,6 +99,10 @@ jobs: env: DOTNET_INSTALL_DIR: ${{ env.DOTNET_ROOT }} + - name: Setup Deno + if: inputs.testShortName == 'Hosting.JavaScript' + uses: ./.github/actions/setup-deno + - name: Set up Java if: ${{ fromJson(inputs.properties).requiresJava == true }} uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index eb20267163b..dc01e0ff2d9 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -85,12 +85,18 @@ Aspire terminal + + Aspire terminal closed before its process started. + Aspire terminal command arguments cannot contain control characters. Aspire terminal command syntax can only contain command names and flags. + + Aspire terminal process failed to start. + Aspire: Launch default AppHost @@ -274,6 +280,9 @@ Define your distributed app in code, run it from VS Code, and see the whole system in the Aspire Dashboard. + + Deno task launches cannot be debugged automatically because Deno does not accept inspector flags on the task subcommand. Use a direct Deno entrypoint for Aspire debugging, or configure and launch a Deno task that starts with an inspector flag manually. + Deploy AppHost @@ -373,6 +382,9 @@ Extension settings + + Failed to allocate a Deno inspector port. + Failed to configure launch.json: {0}. diff --git a/extension/package.nls.json b/extension/package.nls.json index 887b1e468a8..18191730f6d 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -124,6 +124,8 @@ "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", "aspire-vscode.strings.processExitedWithCode": "Process exited with code {0}.", "aspire-vscode.strings.failedToStartPythonProgram": "Failed to start Python program: {0}.", + "aspire-vscode.strings.denoInspectorPortAllocationFailed": "Failed to allocate a Deno inspector port.", + "aspire-vscode.strings.denoTaskDebuggingUnsupported": "Deno task launches cannot be debugged automatically because Deno does not accept inspector flags on the task subcommand. Use a direct Deno entrypoint for Aspire debugging, or configure and launch a Deno task that starts with an inspector flag manually.", "aspire-vscode.strings.csharpSupportNotEnabled": "C# support is not enabled in this workspace. This project should have started through the Aspire CLI.", "aspire-vscode.strings.failedToStartProject": "Failed to start project: {0}.", "aspire-vscode.strings.dcpServerNotInitialized": "DCP server not initialized - cannot forward debug output.", diff --git a/extension/src/capabilities.ts b/extension/src/capabilities.ts index a9a338344ae..9916d296b0f 100644 --- a/extension/src/capabilities.ts +++ b/extension/src/capabilities.ts @@ -21,6 +21,7 @@ export type Capability = | 'node' // Support for running Node.js projects | 'bun' // Support for running Bun projects | 'oven.bun-vscode' // Bun debug adapter extension identifier + | 'deno' // Support for running Deno projects (built-in to VS Code via js-debug) | 'browser' // Support for browser debugging (built-in to VS Code via js-debug) | 'maui' // Support for running .NET MAUI projects | 'ms-dotnettools.dotnet-maui' // MAUI debug adapter extension identifier @@ -157,6 +158,9 @@ export function getSupportedCapabilities(platform: NodeJS.Platform = process.pla capabilities.push("oven.bun-vscode"); } + // Deno debugging uses VS Code's built-in js-debug, so no extension probe is required. + capabilities.push("deno"); + if (isMauiInstalled()) { capabilities.push("maui"); capabilities.push(mauiExtensionId); diff --git a/extension/src/dcp/types.ts b/extension/src/dcp/types.ts index 6977ab5a4d6..a00636aea89 100644 --- a/extension/src/dcp/types.ts +++ b/extension/src/dcp/types.ts @@ -73,7 +73,7 @@ export function isRustLaunchConfiguration(obj: any): obj is RustLaunchConfigurat } export interface JavaScriptRuntimeLaunchConfiguration extends ExecutableLaunchConfiguration { - type: "node" | "bun"; + type: "node" | "bun" | "deno"; script_path?: string; runtime_executable?: string; working_directory?: string; @@ -84,7 +84,7 @@ export interface JavaScriptRuntimeLaunchConfiguration extends ExecutableLaunchCo } export function isJavaScriptRuntimeLaunchConfiguration(obj: any): obj is JavaScriptRuntimeLaunchConfiguration { - return obj && (obj.type === 'node' || obj.type === 'bun'); + return obj && (obj.type === 'node' || obj.type === 'bun' || obj.type === 'deno'); } export type NodeLaunchConfiguration = JavaScriptRuntimeLaunchConfiguration & { type: "node" }; @@ -99,6 +99,12 @@ export function isBunLaunchConfiguration(obj: any): obj is BunLaunchConfiguratio return obj && obj.type === 'bun'; } +export type DenoLaunchConfiguration = JavaScriptRuntimeLaunchConfiguration & { type: "deno" }; + +export function isDenoLaunchConfiguration(obj: any): obj is DenoLaunchConfiguration { + return obj && obj.type === 'deno'; +} + export interface BrowserLaunchConfiguration extends ExecutableLaunchConfiguration { type: "browser"; url?: string; diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index 35d9231de92..5ade19cb891 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -1689,6 +1689,10 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche return await vscode.debug.startDebugging(workspaceFolder, debugConfig, parentSession); } + registerResourceCleanup(disposable: vscode.Disposable): void { + this.registerDisposable(disposable); + } + private getDebugSessionWorkspaceFolder(debugConfig: AspireResourceExtendedDebugConfiguration): vscode.WorkspaceFolder | undefined { const resourcePath = typeof debugConfig.cwd === 'string' ? debugConfig.cwd diff --git a/extension/src/debugger/debuggerExtensions.ts b/extension/src/debugger/debuggerExtensions.ts index 2aab0b6dd6d..45a17b2d08d 100644 --- a/extension/src/debugger/debuggerExtensions.ts +++ b/extension/src/debugger/debuggerExtensions.ts @@ -12,6 +12,7 @@ import { azureFunctionsDebuggerExtension } from "./languages/azureFunctions"; import { goDebuggerExtension } from "./languages/go"; import { createDefaultRustDebuggerExtension } from "./languages/rust"; import { bunDebuggerExtension } from "./languages/bun"; +import { denoDebuggerExtension } from "./languages/deno"; import { javaDebuggerExtension } from "./languages/java"; import { mauiDebuggerExtension } from "./languages/maui"; import { isDirectory } from "../utils/io"; @@ -126,6 +127,8 @@ export function getResourceDebuggerExtensions(platform: NodeJS.Platform = proces extensions.push(bunDebuggerExtension); } + extensions.push(denoDebuggerExtension); + if (isMauiInstalled()) { extensions.push(mauiDebuggerExtension); } diff --git a/extension/src/debugger/languages/deno.ts b/extension/src/debugger/languages/deno.ts new file mode 100644 index 00000000000..72ec72a76f4 --- /dev/null +++ b/extension/src/debugger/languages/deno.ts @@ -0,0 +1,267 @@ +import * as net from 'net'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, JavaScriptRuntimeLaunchConfiguration, LaunchOptions, isJavaScriptRuntimeLaunchConfiguration } from "../../dcp/types"; +import { denoDisplayName, denoInspectorPortAllocationFailed, denoLabel, denoTaskDebuggingUnsupported, invalidLaunchConfiguration } from "../../loc/strings"; +import { extensionLogOutputChannel } from "../../utils/logging"; +import { ResourceDebuggerExtension } from "../debuggerExtensions"; +import { getJavaScriptRuntimeDisplayName, getJavaScriptRuntimeTargetPath, jsRuntimeBaseFileTypes, launchMethodDirect, launchMethodPackageManager, resolveJavaScriptLaunchMethod } from "./javascriptRuntime"; + +// Deno exposes a V8 inspector; --inspect-wait blocks execution until a debugger attaches (unlike +// --inspect-brk it guarantees no early code — including module top-level — runs before attach, which +// is what makes IDE attach reliable). +const denoInspectorHost = '127.0.0.1'; +const reservedDenoInspectorPorts = new Set(); + +// Deno sub-commands that accept runtime flags (so --inspect-wait must be inserted AFTER this token, +// not before it — `deno --inspect-wait run` is invalid). +const denoSubcommandsAcceptingRuntimeFlags = new Set(['run', 'serve', 'test', 'bench']); +const denoFlagsWithSeparateValue = new Set(['--cert', '--config', '--env-file', '--import-map', '--lock', '--location', '--v8-flags']); + +function asDenoConfig(launchConfig: ExecutableLaunchConfiguration): JavaScriptRuntimeLaunchConfiguration { + if (isJavaScriptRuntimeLaunchConfiguration(launchConfig) && launchConfig.type === 'deno') { + return launchConfig; + } + + extensionLogOutputChannel.info(`The resource type was not deno for ${JSON.stringify(launchConfig)}`); + throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig))); +} + +interface DenoInspectFlag { + index: number; + flagName: string; + port?: number; +} + +function findDenoInspectFlag(args: string[], config: JavaScriptRuntimeLaunchConfiguration): DenoInspectFlag | undefined { + const runtimeFlagEnd = getDenoRuntimeFlagEndIndex(args, config); + for (let index = 0; index < runtimeFlagEnd; index++) { + const arg = args[index]; + const explicitPortMatch = /^(--inspect(?:-brk|-wait)?)=(?:.*:)?(\d+)$/.exec(arg); + if (explicitPortMatch) { + const explicitPort = Number(explicitPortMatch[2]); + return { + index, + flagName: explicitPortMatch[1], + // Deno treats an explicit port of 0 as "choose an ephemeral port", so the inspector ends up on + // an unknown nonzero port (verified on 2.9.0: `--inspect=127.0.0.1:0` reported ports 61501, + // 61835 and 61931 across runs). Report it as unresolved so the caller substitutes a concrete + // allocated port; otherwise js-debug would attach to port 0 and never connect. + port: explicitPort === 0 ? undefined : explicitPort + }; + } + + const bareMatch = /^(--inspect(?:-brk|-wait)?)$/.exec(arg); + if (bareMatch) { + return { + index, + flagName: bareMatch[1], + port: undefined + }; + } + } + + return undefined; +} + +function getDenoRuntimeFlagEndIndex(args: string[], config: JavaScriptRuntimeLaunchConfiguration): number { + const startIndex = args.length > 0 && denoSubcommandsAcceptingRuntimeFlags.has(args[0]) ? 1 : 0; + for (let index = startIndex; index < args.length; index++) { + const arg = args[index]; + if (isDenoFlagWithSeparateValue(arg)) { + index++; + continue; + } + + if (!arg.startsWith('-') && isDenoEntrypoint(arg, config)) { + return index; + } + } + + return args.length; +} + +function isDenoFlagWithSeparateValue(arg: string): boolean { + const equalsIndex = arg.indexOf('='); + const flagName = equalsIndex >= 0 ? arg.substring(0, equalsIndex) : arg; + return equalsIndex < 0 && denoFlagsWithSeparateValue.has(flagName); +} + +function isDenoEntrypoint(arg: string, config: JavaScriptRuntimeLaunchConfiguration): boolean { + const scriptPath = config.script_path; + if (!scriptPath) { + return true; + } + + if (arePathsEqual(arg, scriptPath)) { + return true; + } + + if (!config.working_directory) { + return false; + } + + const candidatePath = path.isAbsolute(arg) + ? arg + : path.join(config.working_directory, arg); + return arePathsEqual(candidatePath, scriptPath); +} + +function arePathsEqual(left: string, right: string): boolean { + const normalizedLeft = path.normalize(left); + const normalizedRight = path.normalize(right); + return process.platform === 'win32' + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +async function getAvailableTcpPort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.once('error', reject); + server.listen(0, denoInspectorHost, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + reject(new Error(denoInspectorPortAllocationFailed)); + return; + } + + const port = address.port; + server.close(error => error ? reject(error) : resolve(port)); + }); + }); +} + +async function allocateDenoInspectorPort(): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + const port = await getAvailableTcpPort(); + if (!reservedDenoInspectorPorts.has(port)) { + reservedDenoInspectorPorts.add(port); + return port; + } + } + + throw new Error(denoInspectorPortAllocationFailed); +} + +function registerDenoInspectorPortRelease(port: number, launchOptions: LaunchOptions): void { + let released = false; + const releasePort = () => { + if (!released) { + released = true; + reservedDenoInspectorPorts.delete(port); + } + }; + + let debugSessionTermination: vscode.Disposable | undefined; + const disposeRelease = () => { + releasePort(); + debugSessionTermination?.dispose(); + }; + + debugSessionTermination = vscode.debug.onDidTerminateDebugSession(session => { + if (session.configuration.runId === launchOptions.runId && + session.configuration.debugSessionId === launchOptions.debugSessionId) { + disposeRelease(); + } + }); + + launchOptions.debugSession.registerResourceCleanup({ + dispose: disposeRelease + }); +} + +/** + * Injects `--inspect-wait` into a Deno argument vector so VS Code's built-in js-debug (pwa-node) can + * attach. The flag is placed immediately after a leading sub-command that accepts runtime flags + * (run/serve/test/bench) so it is parsed as a runtime flag rather than a script argument. `deno task` + * does not accept inspector flags, so debug task launches fail fast instead of starting a + * nonfunctional attach session. No-debug launches are left unchanged. An existing inspector flag + * with a concrete nonzero port is preserved; a bare flag or port 0 is rewritten with an allocated + * port so js-debug has a usable attach target. + */ +async function withDenoInspectWait(args: string[], config: JavaScriptRuntimeLaunchConfiguration, launchOptions: LaunchOptions): Promise<{ runtimeArgs: string[]; port?: number }> { + if (!launchOptions.debug) { + return { runtimeArgs: [...args] }; + } + + if (args[0] === 'task') { + extensionLogOutputChannel.info('Skipping Deno debug launch for deno task because Deno does not accept runtime inspector flags on the task subcommand.'); + throw new Error(denoTaskDebuggingUnsupported); + } + + const existingInspectFlag = findDenoInspectFlag(args, config); + if (existingInspectFlag?.port !== undefined) { + return { runtimeArgs: [...args], port: existingInspectFlag.port }; + } + + if (existingInspectFlag !== undefined) { + const port = await allocateDenoInspectorPort(); + registerDenoInspectorPortRelease(port, launchOptions); + const runtimeArgs = [...args]; + runtimeArgs[existingInspectFlag.index] = `${existingInspectFlag.flagName}=${denoInspectorHost}:${port}`; + return { runtimeArgs, port }; + } + + const port = await allocateDenoInspectorPort(); + registerDenoInspectorPortRelease(port, launchOptions); + const runtimeArgs = [...args]; + const insertAt = runtimeArgs.length > 0 && denoSubcommandsAcceptingRuntimeFlags.has(runtimeArgs[0]) ? 1 : 0; + runtimeArgs.splice(insertAt, 0, `--inspect-wait=${denoInspectorHost}:${port}`); + return { runtimeArgs, port }; +} + +export const denoDebuggerExtension: ResourceDebuggerExtension = { + resourceType: 'deno', + // Deno debugging uses js-debug's pwa-node adapter (VS Code built-in, no third-party extension): + // it launches the Deno process and attaches to its V8 inspector via attachSimplePort. outputCapture + // 'std' forwards stdout/stderr as DAP output events for dashboard log forwarding. + debugAdapter: 'pwa-node', + extensionId: null, + getDisplayName: (launchConfig) => getJavaScriptRuntimeDisplayName(launchConfig, 'deno', denoDisplayName, denoLabel), + // Deno runs TypeScript and JSX/TSX natively, so it supports the same file types as Bun. + getSupportedFileTypes: () => [...jsRuntimeBaseFileTypes, '.jsx', '.tsx'], + getProjectFile: (launchConfig) => getJavaScriptRuntimeTargetPath(asDenoConfig(launchConfig)), + createDebugSessionConfigurationCallback: async (launchConfig, args, _env, launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise => { + const config = asDenoConfig(launchConfig); + debugConfiguration.type = 'pwa-node'; + debugConfiguration.outputCapture = 'std'; + + if (config.working_directory) { + debugConfiguration.cwd = config.working_directory; + } + + // Deno is always launched as `deno [flags] [script-args]`: the hosting + // side emits the complete argument vector (run/task/serve mode already resolved), so — unlike + // node/bun — there is no separate "program" file to hoist. Drive js-debug purely through + // runtimeExecutable + runtimeArgs and let it attach to the inspector. + debugConfiguration.runtimeExecutable = config.runtime_executable || 'deno'; + + const launchMethod = resolveJavaScriptLaunchMethod( + config, + () => args?.[0] === 'task' ? launchMethodPackageManager : launchMethodDirect); + if (launchOptions.debug && launchMethod === launchMethodPackageManager) { + extensionLogOutputChannel.info('Skipping Deno debug launch for a package-manager task because Deno does not accept runtime inspector flags on the task subcommand.'); + throw new Error(denoTaskDebuggingUnsupported); + } + + const { runtimeArgs, port } = await withDenoInspectWait(args ?? [], config, launchOptions); + debugConfiguration.runtimeArgs = runtimeArgs; + + if (port !== undefined) { + // attachSimplePort tells js-debug to spawn the runtime and then attach to this inspector port + // rather than expecting a Node bootstrap. Paired with --inspect-wait this is the reliable + // Deno attach path. + debugConfiguration.attachSimplePort = port; + } + + // program/args are meaningless for the pwa-node simple-attach path; remove any defaults set + // upstream so js-debug does not try to launch a node script. + delete debugConfiguration.program; + delete debugConfiguration.args; + + debugConfiguration.resolveSourceMapLocations = ['**', '!**/node_modules/**']; + } +}; diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 083df352649..17d0d884bb8 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -70,6 +70,8 @@ export const launchingWithAppHost = (sessionType: 'run' | 'debug', appHostPath: export const disconnectingFromSession = vscode.l10n.t('Disconnecting from Aspire debug session... Child processes will be stopped.'); export const processExitedWithCode = (code: number | string) => vscode.l10n.t('Process exited with code {0}.', code); export const failedToStartPythonProgram = (errorMessage: string) => vscode.l10n.t('Failed to start Python program: {0}.', errorMessage); +export const denoInspectorPortAllocationFailed = vscode.l10n.t('Failed to allocate a Deno inspector port.'); +export const denoTaskDebuggingUnsupported = vscode.l10n.t('Deno task launches cannot be debugged automatically because Deno does not accept inspector flags on the task subcommand. Use a direct Deno entrypoint for Aspire debugging, or configure and launch a Deno task that starts with an inspector flag manually.'); export const csharpSupportNotEnabled = vscode.l10n.t('C# support is not enabled in this workspace. This project should have started through the Aspire CLI.'); export const failedToStartProject = (errorMessage: string) => vscode.l10n.t('Failed to start project: {0}.', errorMessage); export const dcpServerNotInitialized = vscode.l10n.t('DCP server not initialized - cannot forward debug output.'); @@ -205,6 +207,8 @@ export const rustWindowsGnuDebuggerUnsupported = (target: string) => vscode.l10n export const rustDebuggerExtensionNotInstalled = (extensionId: string) => vscode.l10n.t('Rust AppHosts require native debugger support. Set up {0} in the Extensions view, then start the AppHost again.', extensionId); export const bunDisplayName = (script: string) => `Bun: ${script}`; export const bunLabel = 'Bun'; +export const denoDisplayName = (script: string) => `Deno: ${script}`; +export const denoLabel = 'Deno'; export const nodeDisplayName = (script: string) => `Node.js: ${script}`; export const nodeLabel = 'Node.js'; // Neutral wording: a required extension that is installed but disabled is absent from diff --git a/extension/src/test-e2e/denoDebugger.e2e.test.ts b/extension/src/test-e2e/denoDebugger.e2e.test.ts new file mode 100644 index 00000000000..5fce7b89b21 --- /dev/null +++ b/extension/src/test-e2e/denoDebugger.e2e.test.ts @@ -0,0 +1,83 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { waitForRepositoryIdle, waitForSelectedWorkspaceAppHost } from './helpers/assertions'; +import { createEmptyAppHostProject, executeE2eControlCommand, removeGeneratedProject, restoreWorkspaceAppHostConfig, runE2eTeardown, stopPrimaryAppHostIfRunning, writeFileWithRetry, writeWorkspaceAppHostConfigForPath } from './helpers/fixtures'; +import { getRepoRoot } from './helpers/paths'; +import { openAspireView } from './helpers/vscode'; + +suite('Aspire Deno debugger E2E', function () { + this.timeout(420000); + + const projectName = 'AspireE2E.DenoDebugger'; + teardown(async () => { + await runE2eTeardown([ + () => executeE2eControlCommand({ name: 'clearBreakpoints' }), + () => executeE2eControlCommand({ name: 'stopDebugging' }), + () => stopPrimaryAppHostIfRunning(), + () => removeGeneratedProject(projectName), + () => restoreWorkspaceAppHostConfig(), + ], 'Deno debugger E2E teardown failed.'); + }); + + test('attaches js-debug to a Deno resource and hits a TypeScript breakpoint', async () => { + const projectRoot = await createEmptyAppHostProject(projectName); + const appHostPath = path.join(projectRoot, 'apphost.cs'); + + const denoAppDirectory = path.join(projectRoot, 'deno-app'); + fs.mkdirSync(denoAppDirectory, { recursive: true }); + const denoSourcePath = path.join(denoAppDirectory, 'main.ts'); + writeFileWithRetry(denoSourcePath, `const message = "deno debugger breakpoint hit"; +console.log(message); +Deno.serve({ hostname: "127.0.0.1", port: Number(Deno.env.get("PORT") ?? "8000") }, () => new Response(message)); +`); + + const originalAppHostSource = fs.readFileSync(appHostPath, 'utf8'); + const javascriptHostingProject = path.relative(projectRoot, path.join(getRepoRoot(), 'src', 'Aspire.Hosting.JavaScript', 'Aspire.Hosting.JavaScript.csproj')).replace(/\\/g, '/'); + writeFileWithRetry(path.join(projectRoot, 'Directory.Build.targets'), ` + + + + +`); + const appHostWithLocalJavaScriptHosting = originalAppHostSource.replace( + /(#:sdk [^\r\n]+)(\r?\n)/, + `$1$2#:property NoWarn=ASPIREDENO001$2#:project ${javascriptHostingProject}$2`); + assert.notStrictEqual(appHostWithLocalJavaScriptHosting, originalAppHostSource, 'Expected generated AppHost source to contain an SDK directive.'); + + const appHostSource = appHostWithLocalJavaScriptHosting.replace( + 'builder.Build().Run();', + `builder.AddDenoApp("deno", "deno-app", "main.ts") + .WithHttpEndpoint(env: "PORT") + .WithExplicitStart(); + +builder.Build().Run();`); + assert.notStrictEqual(appHostSource, appHostWithLocalJavaScriptHosting, 'Expected generated AppHost source to contain builder.Build().Run().'); + writeFileWithRetry(appHostPath, appHostSource); + writeWorkspaceAppHostConfigForPath(appHostPath); + + await openAspireView(); + await waitForRepositoryIdle(); + await executeE2eControlCommand({ name: 'refreshAppHosts' }); + await waitForSelectedWorkspaceAppHost(appHostPath, 180000); + + const proof = await executeE2eControlCommand({ + name: 'proveDenoResourceDebugging', + appHostPath, + resourceName: 'deno', + sourcePath: denoSourcePath, + breakpointLine: 1, + timeoutMs: 300000, + }, { timeoutMs: 330000 }); + + assert.strictEqual(proof.status, 'applied'); + assert.ok(isRecord(proof.result), `Expected Deno debug proof result. Actual result: ${JSON.stringify(proof.result)}`); + assert.strictEqual(proof.result.proof, 'aspire-deno-resource-debug-breakpoint-hit'); + assert.strictEqual(proof.result.resourceName, 'deno'); + }); +}); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/extension/src/test/denoDebugger.test.ts b/extension/src/test/denoDebugger.test.ts new file mode 100644 index 00000000000..6dc7e36e25f --- /dev/null +++ b/extension/src/test/denoDebugger.test.ts @@ -0,0 +1,355 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { AspireDebugSession } from '../debugger/AspireDebugSession'; +import { getSupportedCapabilities } from '../capabilities'; +import { denoDebuggerExtension } from '../debugger/languages/deno'; +import { cleanupRun } from '../debugger/runCleanupRegistry'; +import { AspireResourceExtendedDebugConfiguration, DenoLaunchConfiguration } from '../dcp/types'; + +suite('Deno Debugger Tests', () => { + const fakeAspireDebugSession = Object.create(AspireDebugSession.prototype) as AspireDebugSession; + let registeredCleanupCount = 0; + let resourceCleanups: vscode.Disposable[] = []; + let terminateDebugSessionCallback: ((session: vscode.DebugSession) => void) | undefined; + let terminateDebugSessionListenerDisposeCount = 0; + + fakeAspireDebugSession.registerResourceCleanup = cleanup => { + registeredCleanupCount++; + resourceCleanups.push(cleanup); + }; + + setup(() => { + registeredCleanupCount = 0; + resourceCleanups = []; + terminateDebugSessionCallback = undefined; + terminateDebugSessionListenerDisposeCount = 0; + sinon.stub(vscode.debug, 'onDidTerminateDebugSession').callsFake(callback => { + terminateDebugSessionCallback = callback; + return { + dispose: () => terminateDebugSessionListenerDisposeCount++ + }; + }); + }); + + teardown(() => { + resourceCleanups.forEach(cleanup => cleanup.dispose()); + cleanupRun('1'); + sinon.restore(); + }); + + async function configure(launchConfig: DenoLaunchConfiguration, args: string[], debugConfig: AspireResourceExtendedDebugConfiguration, debug: boolean = true): Promise { + await denoDebuggerExtension.createDebugSessionConfigurationCallback!(launchConfig, args, [], { debug, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, debugConfig); + } + + function assertInjectedInspectWaitArg(arg: string | undefined, debugConfig: AspireResourceExtendedDebugConfiguration): number { + assert.ok(arg); + const match = /^--inspect-wait=127\.0\.0\.1:(\d+)$/.exec(arg); + assert.ok(match); + + const port = Number(match[1]); + assert.ok(port > 0); + assert.strictEqual(debugConfig.attachSimplePort, port); + return port; + } + + test('targets the built-in pwa-node adapter and forwards stdout/stderr', () => { + assert.strictEqual(denoDebuggerExtension.resourceType, 'deno'); + assert.strictEqual(denoDebuggerExtension.debugAdapter, 'pwa-node'); + // Deno debugging needs no third-party debug adapter extension (uses js-debug). + assert.strictEqual(denoDebuggerExtension.extensionId, null); + assert.strictEqual(getSupportedCapabilities().filter(capability => capability === 'deno').length, 1); + }); + + test('runs TypeScript and JSX/TSX natively', () => { + const fileTypes = denoDebuggerExtension.getSupportedFileTypes(); + assert.ok(fileTypes.includes('.ts')); + assert.ok(fileTypes.includes('.tsx')); + assert.ok(fileTypes.includes('.jsx')); + }); + + test('injects --inspect-wait after the run sub-command and drives js-debug via runtimeArgs', async () => { + const launchConfig: DenoLaunchConfiguration = { + type: 'deno', + runtime_executable: 'deno', + script_path: '/workspace/app/main.ts', + working_directory: '/workspace/app' + }; + const debugConfig = createDebugConfig('/workspace/app/main.ts'); + + // Default AddDenoApp direct execution surfaces as ["run", "-A", "main.ts"]. + await configure(launchConfig, ['run', '-A', 'main.ts'], debugConfig); + + assert.strictEqual(debugConfig.type, 'pwa-node'); + assert.strictEqual(debugConfig.outputCapture, 'std'); + assert.strictEqual(debugConfig.cwd, '/workspace/app'); + assert.strictEqual(debugConfig.runtimeExecutable, 'deno'); + // --inspect-wait must be inserted AFTER "run" (it is a runtime flag, not a script arg). + assert.strictEqual(debugConfig.runtimeArgs?.[0], 'run'); + assertInjectedInspectWaitArg(debugConfig.runtimeArgs?.[1], debugConfig); + assert.deepStrictEqual(debugConfig.runtimeArgs?.slice(2), ['-A', 'main.ts']); + assert.strictEqual(registeredCleanupCount, 1); + // The pwa-node simple-attach path drives the launch purely through runtimeExecutable + runtimeArgs. + assert.strictEqual(debugConfig.program, undefined); + assert.strictEqual(debugConfig.args, undefined); + }); + + test('uses a different inspector port for each injected debug session', async () => { + const launchConfig: DenoLaunchConfiguration = { + type: 'deno', + runtime_executable: 'deno', + script_path: '/workspace/app/main.ts', + working_directory: '/workspace/app' + }; + const firstDebugConfig = createDebugConfig('/workspace/app/main.ts'); + const secondDebugConfig = createDebugConfig('/workspace/app/main.ts'); + + await configure(launchConfig, ['run', '-A', 'main.ts'], firstDebugConfig); + await configure(launchConfig, ['run', '-A', 'main.ts'], secondDebugConfig); + + const firstPort = assertInjectedInspectWaitArg(firstDebugConfig.runtimeArgs?.[1], firstDebugConfig); + const secondPort = assertInjectedInspectWaitArg(secondDebugConfig.runtimeArgs?.[1], secondDebugConfig); + assert.notStrictEqual(firstPort, secondPort); + assert.strictEqual(registeredCleanupCount, 2); + }); + + test('does not release inspector port when a sibling debug session in the same run terminates', async () => { + const launchConfig: DenoLaunchConfiguration = { + type: 'deno', + runtime_executable: 'deno', + script_path: '/workspace/app/main.ts', + working_directory: '/workspace/app' + }; + const debugConfig = createDebugConfig('/workspace/app/main.ts'); + + await configure(launchConfig, ['run', '-A', 'main.ts'], debugConfig); + + assert.ok(terminateDebugSessionCallback); + cleanupRun('1'); + + assert.strictEqual(terminateDebugSessionListenerDisposeCount, 0); + + terminateDebugSessionCallback(createTerminatedDebugSession('other-debug-session', '2')); + + assert.strictEqual(terminateDebugSessionListenerDisposeCount, 0); + + terminateDebugSessionCallback(createTerminatedDebugSession('current-debug-session', '1')); + + assert.strictEqual(terminateDebugSessionListenerDisposeCount, 1); + }); + + test('rejects debug launches for deno task commands', async () => { + const launchConfig: DenoLaunchConfiguration = { + type: 'deno', + runtime_executable: 'deno', + script_path: '/workspace/app/deno.json', + working_directory: '/workspace/app' + }; + const debugConfig = createDebugConfig('/workspace/app/deno.json'); + + // .WithRunScript("dev") surfaces as ["task", "dev"]. + await assert.rejects( + () => configure(launchConfig, ['task', 'dev'], debugConfig), + /Deno task launches cannot be debugged automatically/); + + assert.strictEqual(debugConfig.runtimeArgs, undefined); + assert.strictEqual(debugConfig.attachSimplePort, undefined); + assert.strictEqual(registeredCleanupCount, 0); + }); + + test('rejects explicit package-manager launch methods even when the argument shape is unfamiliar', async () => { + const launchConfig: DenoLaunchConfiguration = { + type: 'deno', + launch_method: 'package-manager', + runtime_executable: 'deno', + script_path: '/workspace/app/deno.json', + working_directory: '/workspace/app' + }; + const debugConfig = createDebugConfig('/workspace/app/deno.json'); + + await assert.rejects( + () => configure(launchConfig, ['future-task-shape', 'dev'], debugConfig), + /Deno task launches cannot be debugged automatically/); + + assert.strictEqual(debugConfig.runtimeArgs, undefined); + assert.strictEqual(debugConfig.attachSimplePort, undefined); + assert.strictEqual(registeredCleanupCount, 0); + }); + + test('uses legacy argument inference when launch_method is absent', async () => { + const launchConfig: DenoLaunchConfiguration = { + type: 'deno', + runtime_executable: 'deno', + script_path: '/workspace/app/deno.json', + working_directory: '/workspace/app' + }; + const debugConfig = createDebugConfig('/workspace/app/deno.json'); + + await assert.rejects( + () => configure(launchConfig, ['task', 'dev'], debugConfig), + /Deno task launches cannot be debugged automatically/); + + assert.strictEqual(registeredCleanupCount, 0); + }); + + test('does not inject --inspect-wait for no-debug deno task launches', async () => { + const launchConfig: DenoLaunchConfiguration = { + type: 'deno', + runtime_executable: 'deno', + script_path: '/workspace/app/deno.json', + working_directory: '/workspace/app' + }; + const debugConfig = createDebugConfig('/workspace/app/deno.json'); + + await configure(launchConfig, ['task', 'dev'], debugConfig, false); + + assert.deepStrictEqual(debugConfig.runtimeArgs, ['task', 'dev']); + assert.strictEqual(debugConfig.attachSimplePort, undefined); + assert.strictEqual(registeredCleanupCount, 0); + }); + + test('respects a user-configured inspector flag and does not double-inject', async () => { + const launchConfig: DenoLaunchConfiguration = { + type: 'deno', + runtime_executable: 'deno', + script_path: '/workspace/app/main.ts', + working_directory: '/workspace/app' + }; + const debugConfig = createDebugConfig('/workspace/app/main.ts'); + + // WithDenoInspectBrk("127.0.0.1:9333") surfaces the flag already in the vector. + await configure(launchConfig, ['run', '--inspect-brk=127.0.0.1:9333', '-A', 'main.ts'], debugConfig); + + assert.deepStrictEqual(debugConfig.runtimeArgs, ['run', '--inspect-brk=127.0.0.1:9333', '-A', 'main.ts']); + // attachSimplePort is derived from the user-supplied inspector port. + assert.strictEqual(debugConfig.attachSimplePort, 9333); + assert.strictEqual(registeredCleanupCount, 0); + }); + + test('replaces an explicit inspector port of 0 with an allocated concrete port', async () => { + const launchConfig: DenoLaunchConfiguration = { + type: 'deno', + runtime_executable: 'deno', + script_path: '/workspace/app/main.ts', + working_directory: '/workspace/app' + }; + const debugConfig = createDebugConfig('/workspace/app/main.ts'); + + // Deno reads ":0" as "choose an ephemeral port", so it listens on an unknown nonzero port. + // Attaching to 0 would never connect; the flag must be rewritten to a concrete allocated port. + await configure(launchConfig, ['run', '--inspect-wait=127.0.0.1:0', '-A', 'main.ts'], debugConfig); + + const injectedPort = assertInjectedInspectWaitArg(debugConfig.runtimeArgs?.[1], debugConfig); + assert.notStrictEqual(injectedPort, 0); + assert.deepStrictEqual(debugConfig.runtimeArgs, ['run', `--inspect-wait=127.0.0.1:${injectedPort}`, '-A', 'main.ts']); + assert.strictEqual(registeredCleanupCount, 1); + }); + + test('replaces a bare-host inspector port of 0 on --inspect-brk with an allocated concrete port', async () => { + const launchConfig: DenoLaunchConfiguration = { + type: 'deno', + runtime_executable: 'deno', + script_path: '/workspace/app/main.ts', + working_directory: '/workspace/app' + }; + const debugConfig = createDebugConfig('/workspace/app/main.ts'); + + await configure(launchConfig, ['run', '--inspect-brk=0', '-A', 'main.ts'], debugConfig); + + const injectedArg = debugConfig.runtimeArgs?.[1]; + const match = /^--inspect-brk=127\.0\.0\.1:(\d+)$/.exec(injectedArg ?? ''); + assert.ok(match, `expected an allocated --inspect-brk port, got ${injectedArg}`); + const injectedPort = Number(match[1]); + assert.notStrictEqual(injectedPort, 0); + assert.strictEqual(debugConfig.attachSimplePort, injectedPort); + assert.deepStrictEqual(debugConfig.runtimeArgs, ['run', `--inspect-brk=127.0.0.1:${injectedPort}`, '-A', 'main.ts']); + assert.strictEqual(registeredCleanupCount, 1); + }); + + test('ignores inspector-looking script arguments after the entrypoint', async () => { + const launchConfig: DenoLaunchConfiguration = { + type: 'deno', + runtime_executable: 'deno', + script_path: '/workspace/app/main.ts', + working_directory: '/workspace/app' + }; + const debugConfig = createDebugConfig('/workspace/app/main.ts'); + + await configure(launchConfig, ['run', '-A', 'main.ts', '--inspect=9229'], debugConfig); + + assert.strictEqual(debugConfig.runtimeArgs?.[0], 'run'); + const injectedPort = assertInjectedInspectWaitArg(debugConfig.runtimeArgs?.[1], debugConfig); + assert.notStrictEqual(injectedPort, 9229); + assert.deepStrictEqual(debugConfig.runtimeArgs?.slice(2), ['-A', 'main.ts', '--inspect=9229']); + assert.strictEqual(registeredCleanupCount, 1); + }); + + test('allocates a different inspector port for each bare user-configured inspector flag', async () => { + const launchConfig: DenoLaunchConfiguration = { + type: 'deno', + runtime_executable: 'deno', + script_path: '/workspace/app/main.ts', + working_directory: '/workspace/app' + }; + const firstDebugConfig = createDebugConfig('/workspace/app/main.ts'); + const secondDebugConfig = createDebugConfig('/workspace/app/main.ts'); + + await configure(launchConfig, ['run', '--inspect-wait', '-A', 'main.ts'], firstDebugConfig); + await configure(launchConfig, ['run', '--inspect-wait', '-A', 'main.ts'], secondDebugConfig); + + assert.strictEqual(firstDebugConfig.runtimeArgs?.[0], 'run'); + const firstPort = assertInjectedInspectWaitArg(firstDebugConfig.runtimeArgs?.[1], firstDebugConfig); + assert.deepStrictEqual(firstDebugConfig.runtimeArgs?.slice(2), ['-A', 'main.ts']); + + assert.strictEqual(secondDebugConfig.runtimeArgs?.[0], 'run'); + const secondPort = assertInjectedInspectWaitArg(secondDebugConfig.runtimeArgs?.[1], secondDebugConfig); + assert.deepStrictEqual(secondDebugConfig.runtimeArgs?.slice(2), ['-A', 'main.ts']); + + assert.notStrictEqual(firstPort, secondPort); + assert.strictEqual(registeredCleanupCount, 2); + }); + + test('falls back to the deno executable when runtime_executable is absent', async () => { + const launchConfig: DenoLaunchConfiguration = { + type: 'deno', + script_path: '/workspace/app/main.ts', + working_directory: '/workspace/app' + }; + const debugConfig = createDebugConfig('/workspace/app/main.ts'); + + await configure(launchConfig, ['run', '-A', 'main.ts'], debugConfig); + + assert.strictEqual(debugConfig.runtimeExecutable, 'deno'); + assertInjectedInspectWaitArg(debugConfig.runtimeArgs?.[1], debugConfig); + }); +}); + +function createDebugConfig(program: string = '/workspace/app/main.ts'): AspireResourceExtendedDebugConfiguration { + return { + runId: '1', + debugSessionId: '1', + type: 'deno', + name: 'Deno', + request: 'launch', + program, + args: [] + }; +} + +function createTerminatedDebugSession(id: string, debugSessionId: string): vscode.DebugSession { + return { + id, + type: 'deno', + name: 'Deno', + workspaceFolder: undefined, + configuration: { + type: 'deno', + name: 'Deno', + request: 'launch', + runId: '1', + debugSessionId + }, + customRequest: async (_command: string, _args?: unknown) => undefined, + getDebugProtocolBreakpoint: async (_breakpoint: vscode.Breakpoint) => undefined + }; +} diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index 80b66da5153..efdfa15668e 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -774,7 +774,17 @@ export async function executeE2eControlCommand( } case 'proveMauiResourceDebugging': { markStarted(); - return await proveMauiResourceDebugging(command, aspireContext, appHostTreeProvider, terminalProvider); + return await proveResourceDebugging(command, aspireContext, appHostTreeProvider, terminalProvider, { + displayName: 'MAUI', + proof: 'aspire-maui-resource-debug-breakpoint-hit', + }); + } + case 'proveDenoResourceDebugging': { + markStarted(); + return await proveResourceDebugging(command, aspireContext, appHostTreeProvider, terminalProvider, { + displayName: 'Deno', + proof: 'aspire-deno-resource-debug-breakpoint-hit', + }); } case 'getExtensionPackageJson': { markStarted(); @@ -1027,8 +1037,13 @@ function getE2eEnvVars(value: unknown): EnvVar[] { return value.map(item => ({ name: item.name, value: item.value })); } +type ResourceDebugProofCommand = Extract; + +interface ResourceDebugProofOptions { + displayName: string; + proof: string; +} type AppHostAndResourceDebugProofCommand = Extract; -type MauiResourceDebugProofCommand = Extract; interface DebugSessionSnapshot { id: string; @@ -1285,10 +1300,10 @@ ${JSON.stringify({ } } -async function proveMauiResourceDebugging(command: MauiResourceDebugProofCommand, aspireContext: AspireExtensionContext, appHostTreeProvider: AspireAppHostTreeProvider, terminalProvider: AspireTerminalProvider): Promise { +async function proveResourceDebugging(command: ResourceDebugProofCommand, aspireContext: AspireExtensionContext, appHostTreeProvider: AspireAppHostTreeProvider, terminalProvider: AspireTerminalProvider, options: ResourceDebugProofOptions): Promise { const appHostPath = getE2eWorkspacePath(command.appHostPath); const sourcePath = getE2eWorkspacePath(command.sourcePath); - const resourceName = getE2eRequiredString(command.resourceName, 'Aspire extension E2E MAUI proof requires resourceName.'); + const resourceName = getE2eRequiredString(command.resourceName, `Aspire extension E2E ${options.displayName} proof requires resourceName.`); const breakpointLine = getE2eBreakpointLine(command.breakpointLine); const timeoutMs = getE2ePositiveInteger(command.timeoutMs, 300000, 'timeoutMs'); const pauseOnBreakpointMs = getE2ePositiveInteger(command.pauseOnBreakpointMs, 0, 'pauseOnBreakpointMs'); @@ -1407,7 +1422,7 @@ async function proveMauiResourceDebugging(command: MauiResourceDebugProofCommand let stoppedEvent: { stoppedEvent: DebugAdapterStoppedEvent; stackTrace: { stackFrames?: Array<{ source?: { path?: string }; line?: number }> }; matchingFrame: { source?: { path?: string }; line?: number } }; try { stoppedEvent = await waitForE2eValue( - `MAUI breakpoint in ${sourcePath}:${breakpointLine + 1}`, + `${options.displayName} breakpoint in ${sourcePath}:${breakpointLine + 1}`, breakpointTimeoutMs, async () => { for (const stoppedEvent of stoppedEvents) { @@ -1432,7 +1447,9 @@ async function proveMauiResourceDebugging(command: MauiResourceDebugProofCommand continue; } const matchingFrame = stackTrace?.stackFrames?.find((frame: { source?: { path?: string }; line?: number }) => - typeof frame.source?.path === 'string' && isSamePath(frame.source.path, sourcePath)); + typeof frame.source?.path === 'string' && + isSamePath(frame.source.path, sourcePath) && + frame.line === breakpointLine + 1); if (matchingFrame) { return { stoppedEvent, stackTrace: stackTrace!, matchingFrame }; } @@ -1457,7 +1474,7 @@ ${JSON.stringify({ } if (stoppedEvent.matchingFrame.line !== breakpointLine + 1) { - throw new Error(`Expected MAUI breakpoint line ${breakpointLine + 1}, got ${stoppedEvent.matchingFrame.line}.`); + throw new Error(`Expected ${options.displayName} breakpoint line ${breakpointLine + 1}, got ${stoppedEvent.matchingFrame.line}.`); } if (pauseOnBreakpointMs > 0) { @@ -1465,7 +1482,7 @@ ${JSON.stringify({ } return { - proof: 'aspire-maui-resource-debug-breakpoint-hit', + proof: options.proof, appHostPath, resourceName, timeouts: { diff --git a/extension/src/types/extensionApi.ts b/extension/src/types/extensionApi.ts index 9dfe54d461f..6703a93d68c 100644 --- a/extension/src/types/extensionApi.ts +++ b/extension/src/types/extensionApi.ts @@ -274,4 +274,5 @@ export type AspireExtensionE2EControlCommand = environmentKeys?: readonly string[]; } | { name: 'proveAppHostAndResourceDebugging'; appHostPath: string; resourceName: string; appHostSourcePath: string; appHostBreakpointLine: number; resourceSourcePath: string; resourceBreakpointLine: number; resourceRequestPath?: string; timeoutMs?: number } - | { name: 'proveMauiResourceDebugging'; appHostPath: string; resourceName: string; sourcePath: string; breakpointLine: number; timeoutMs?: number; pauseOnBreakpointMs?: number }; + | { name: 'proveMauiResourceDebugging'; appHostPath: string; resourceName: string; sourcePath: string; breakpointLine: number; timeoutMs?: number; pauseOnBreakpointMs?: number } + | { name: 'proveDenoResourceDebugging'; appHostPath: string; resourceName: string; sourcePath: string; breakpointLine: number; timeoutMs?: number; pauseOnBreakpointMs?: number }; 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..9cf2f0d6465 --- /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.9.0) + +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.Docker/DockerComposeAspireDashboardResource.cs b/src/Aspire.Hosting.Docker/DockerComposeAspireDashboardResource.cs index 9d6ea4d702b..b5d8be58db6 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeAspireDashboardResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeAspireDashboardResource.cs @@ -22,4 +22,6 @@ public class DockerComposeAspireDashboardResource(string name) : ContainerResour /// Gets the OTLP gRPC endpoint for telemetry data. /// public EndpointReference OtlpGrpcEndpoint => new(this, "otlp-grpc"); + + internal EndpointReference OtlpHttpEndpoint => new(this, "otlp-http"); } diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index d0e0f3a1439..22347d8c4a0 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -339,9 +339,9 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) } // Configure OTLP for resources if dashboard is enabled (before creating the service resource) - if (DashboardEnabled && Dashboard?.Resource.OtlpGrpcEndpoint is EndpointReference otlpGrpcEndpoint) + if (DashboardEnabled && Dashboard?.Resource is { } dashboardResource) { - ConfigureOtlp(r, otlpGrpcEndpoint); + ConfigureOtlp(r, dashboardResource); } // Create a Docker Compose compute resource for the resource @@ -382,22 +382,50 @@ private static IContainerRegistry GetContainerRegistry(DockerComposeEnvironmentR return LocalContainerRegistry.Instance; } - private static void ConfigureOtlp(IResource resource, EndpointReference otlpEndpoint) + private static void ConfigureOtlp(IResource resource, DockerComposeAspireDashboardResource dashboard) { + var otlpExporter = GetEffectiveOtlpExporterAnnotation(resource); + // Only configure OTLP for resources that have the OtlpExporterAnnotation and implement IResourceWithEnvironment - if (resource is IResourceWithEnvironment resourceWithEnv && resource.Annotations.OfType().Any()) + if (resource is IResourceWithEnvironment resourceWithEnv && + otlpExporter is not null) { - // Configure OTLP environment variables + var (otlpEndpoint, protocol) = otlpExporter.RequiredProtocol switch + { + OtlpProtocol.HttpProtobuf => (dashboard.OtlpHttpEndpoint, "http/protobuf"), + OtlpProtocol.HttpJson => (dashboard.OtlpHttpEndpoint, "http/json"), + _ => (dashboard.OtlpGrpcEndpoint, "grpc"), + }; + resourceWithEnv.Annotations.Add(new EnvironmentCallbackAnnotation(context => { context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpEndpoint] = otlpEndpoint; - context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpProtocol] = "grpc"; + context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpProtocol] = protocol; context.EnvironmentVariables[KnownOtelConfigNames.ServiceName] = resource.Name; + ApplyActivationEnvironmentVariables(resource, context.EnvironmentVariables); return Task.CompletedTask; })); } } + private static void ApplyActivationEnvironmentVariables( + IResource resource, + IDictionary environmentVariables) + { + if (GetEffectiveOtlpExporterAnnotation(resource) is not IReadOnlyDictionary activationEnvironmentVariables) + { + return; + } + + foreach (var (name, value) in activationEnvironmentVariables) + { + environmentVariables.TryAdd(name, value); + } + } + + private static OtlpExporterAnnotation? GetEffectiveOtlpExporterAnnotation(IResource resource) + => resource.Annotations.OfType().LastOrDefault(); + private async Task DockerComposeUpAsync(PipelineStepContext context) { var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this); diff --git a/src/Aspire.Hosting.JavaScript/DenoAppResource.cs b/src/Aspire.Hosting.JavaScript/DenoAppResource.cs new file mode 100644 index 00000000000..e6a9a7ebf11 --- /dev/null +++ b/src/Aspire.Hosting.JavaScript/DenoAppResource.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +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)] +[Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class DenoAppResource(string name, string command, string workingDirectory) + : JavaScriptAppResource(name, command, workingDirectory), IResourceWithServiceDiscovery, IContainerFilesDestinationResource; diff --git a/src/Aspire.Hosting.JavaScript/DenoCommandLineAnnotation.cs b/src/Aspire.Hosting.JavaScript/DenoCommandLineAnnotation.cs new file mode 100644 index 00000000000..9ec908f2094 --- /dev/null +++ b/src/Aspire.Hosting.JavaScript/DenoCommandLineAnnotation.cs @@ -0,0 +1,200 @@ +// 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 // Deno APIs use this implementation type internally + +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.JavaScript; + +/// +/// The Deno sub-command a is launched with. +/// +internal enum DenoCommandMode +{ + /// Execute a script entrypoint with deno run. + Run, + + /// Execute a named task from deno.json with deno task. + Task, + + /// Serve an HTTP entrypoint with deno serve. + Serve, +} + +/// +/// The Deno inspector flavor. +/// +[Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public enum DenoInspectMode +{ + /// --inspect — attach a debugger; execution starts immediately. + Inspect, + + /// --inspect-brk — attach a debugger and break on the first statement. + InspectBrk, + + /// --inspect-wait — wait for a debugger to attach before running any code. + InspectWait, +} + +/// +/// Controls how Deno manages a local node_modules directory. +/// +[Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public enum DenoNodeModulesDirMode +{ + /// Do not use a local node_modules directory. + None, + + /// Automatically manage a local node_modules directory when packages require it. + Auto, + + /// Use a manually managed local node_modules directory. + Manual, +} + +/// +/// A granular Deno permission grant/denial (for example --allow-net or --deny-read). +/// +[Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public enum DenoPermissionKind +{ + /// Network access controlled by --allow-net or --deny-net. + Net, + + /// File-system read access controlled by --allow-read or --deny-read. + Read, + + /// File-system write access controlled by --allow-write or --deny-write. + Write, + + /// Subprocess execution controlled by --allow-run or --deny-run. + Run, + + /// Environment-variable access controlled by --allow-env or --deny-env. + Env, + + /// Remote import access controlled by --allow-import or --deny-import. + Import, + + /// System-information access controlled by --allow-sys or --deny-sys. + Sys, + + /// Foreign-function-interface access controlled by --allow-ffi or --deny-ffi. + Ffi, +} + +/// +/// A single granular Deno permission flag with an optional comma-separated value list. +/// +internal sealed class DenoPermission +{ + public required DenoPermissionKind Kind { get; init; } + + public required bool Deny { get; init; } + + public IReadOnlyList Values { get; init; } = []; + + /// + /// The flag name segment used to build --allow-{name}/--deny-{name}. + /// + public string Name => Kind switch + { + DenoPermissionKind.Net => "net", + DenoPermissionKind.Read => "read", + DenoPermissionKind.Write => "write", + DenoPermissionKind.Run => "run", + DenoPermissionKind.Env => "env", + DenoPermissionKind.Import => "import", + DenoPermissionKind.Sys => "sys", + DenoPermissionKind.Ffi => "ffi", + _ => Kind.ToString().ToLowerInvariant(), + }; +} + +/// +/// Captures the full Deno command-line surface for a . +/// +/// +/// When present on a Deno resource this annotation fully controls the emitted argument list, replacing the +/// default deno run -A <script> form. The fluent WithDeno* extension methods mutate a single +/// instance of this annotation so flags compose predictably regardless of call order, and the resulting args are +/// emitted in valid Deno CLI order: deno <mode> [runtime-flags] <entrypoint> [script-args]. +/// +internal sealed class DenoCommandLineAnnotation : IResourceAnnotation +{ + /// The sub-command mode (run, task, or serve). Defaults to run. + public DenoCommandMode Mode { get; set; } = DenoCommandMode.Run; + + /// Whether a fluent mode method explicitly selected . + public bool ModeSet { get; set; } + + /// + /// The HTTP endpoint used by WithDenoServe. + /// + public EndpointAnnotation? ServeEndpoint { get; set; } + + /// Whether was created by WithDenoServe. + public bool ServeEndpointCreated { get; set; } + + /// + /// The environment callback created when WithDenoServe maps the endpoint target port to PORT. + /// + public EnvironmentCallbackAnnotation? ServeEnvironmentCallback { get; set; } + + /// The publish target port assigned by WithDenoServe, if it supplied one. + public int? ServeAssignedTargetPort { get; set; } + + /// The task name to invoke when is . + public string? TaskName { get; set; } + + /// + /// Tri-state -A/--allow-all control. means "default": emit -A + /// only when no granular allow permission has been configured (preserving backward-compatible behavior). + /// + public bool? AllowAll { get; set; } + + /// Granular permission grants/denials. + public List Permissions { get; } = []; + + /// --config <file>. + public string? ConfigFile { get; set; } + + /// --import-map <file>. + public string? ImportMap { get; set; } + + /// --lock <file>. + public string? Lock { get; set; } + + /// --no-lock. + public bool NoLock { get; set; } + + /// Whether --node-modules-dir was requested. + public bool NodeModulesDirSet { get; set; } + + /// Optional mode for --node-modules-dir=<mode>. + public DenoNodeModulesDirMode? NodeModulesDirMode { get; set; } + + /// Fully-formed --unstable-* flags. + public List UnstableFlags { get; } = []; + + /// --watch. + public bool Watch { get; set; } + + /// --watch-hmr. + public bool WatchHmr { get; set; } + + /// The inspector flavor, if any. + public DenoInspectMode? Inspect { get; set; } + + /// Optional host:port for the inspector flag. + public string? InspectHostPort { get; set; } + + /// Raw runtime args injected verbatim BEFORE the entrypoint (escape hatch / AddExecutable parity). + public List RuntimeArgs { get; } = []; + + /// Args passed to the script AFTER the entrypoint. + public List ScriptArgs { get; } = []; +} diff --git a/src/Aspire.Hosting.JavaScript/DenoHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/DenoHostingExtensions.cs new file mode 100644 index 00000000000..f7c3f9aca5c --- /dev/null +++ b/src/Aspire.Hosting.JavaScript/DenoHostingExtensions.cs @@ -0,0 +1,1430 @@ +// 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 // Deno APIs use the experimental Deno resource and enums internally + +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.JavaScript; + +namespace Aspire.Hosting; + +/// +/// Fluent flag-surface extensions for . +/// +/// +/// These methods let a caller express the full Deno CLI flag surface (permissions, resolution flags, unstable +/// features, watch/inspect, sub-command modes, and script args) directly on AddDenoApp, so a Deno workload +/// no longer has to fall back to a raw AddExecutable("name", "deno", ...). All methods mutate a single +/// ; flags compose regardless of call order and are emitted in valid Deno +/// CLI order: deno <mode> [runtime-flags] <entrypoint> [script-args]. +/// +public static partial class JavaScriptHostingExtensions +{ + private const int DenoServeDefaultPort = 8000; + + private static DenoCommandLineAnnotation GetOrAddDenoAnnotation(IResourceBuilder builder) + { + if (!builder.Resource.TryGetLastAnnotation(out var annotation)) + { + annotation = new DenoCommandLineAnnotation(); + builder.WithAnnotation(annotation); + } + + return annotation; + } + + private static IResourceBuilder AddDenoPermission( + IResourceBuilder builder, + DenoPermissionKind kind, + bool deny, + string[] values) + { + ArgumentNullException.ThrowIfNull(builder); + if (!Enum.IsDefined(kind)) + { + throw new ArgumentOutOfRangeException(nameof(kind), kind, "The permission kind must be a defined DenoPermissionKind value."); + } + + // The caller owns the params array and can keep mutating it after this call. Permissions are only read + // when the command line is materialized (publish, or resource start), so holding the caller's array by + // reference would let a later mutation silently rewrite the launch arguments. Snapshot it, matching the + // copy semantics WithDenoScriptArgs and WithDenoRuntimeArgs already get from AddRange. + string[] snapshot = values is null ? [] : [.. values]; + var permission = new DenoPermission + { + Kind = kind, + Deny = deny, + Values = snapshot, + }; + + // Deno delimits permission values with commas and offers no escape syntax, so a single value containing a + // comma silently becomes several permissions. Verified on Deno 2.9.0: `--allow-read=data,secret` intended as + // one directory named "data,secret" instead grants `data` and `secret` separately, so the requested path is + // denied while unrelated paths are granted. Reject it here rather than emit a command line that means + // something other than what the caller asked for. + // + // An empty params array intentionally emits an unscoped flag, but an individual null or empty value emits + // `--allow-read=` (or the equivalent permission) and Deno 2.9 rejects it. Do not trim values: Deno accepts + // whitespace as a permission value. + foreach (var value in snapshot) + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException("Deno permission values cannot be null or empty.", nameof(values)); + } + + if (value.Contains(',')) + { + var flag = permission.Deny ? $"--deny-{permission.Name}" : $"--allow-{permission.Name}"; + throw new ArgumentException($"The value '{value}' cannot contain a comma. Deno separates {flag} values with commas and provides no way to escape them, so this value would be interpreted as multiple permissions. Pass each value as a separate argument.", nameof(values)); + } + } + + var annotation = GetOrAddDenoAnnotation(builder); + annotation.Permissions.Add(permission); + return builder; + } + + // ---- Blanket permission ----------------------------------------------------------------- + + /// + /// Controls the blanket -A/--allow-all grant. + /// + /// The Deno app resource builder. + /// + /// Whether to emit -A/--allow-all. Pass to grant only permissions + /// configured with . + /// + /// A reference to the . + /// The resource builder. + /// + /// Without explicit permission configuration, local run mode emits -A for parity with Node and Bun, + /// while generated containers default direct run/serve entrypoints to + /// --allow-net --allow-env. Calling this method with explicitly emits -A + /// in both modes. + /// + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoAllowAll(this IResourceBuilder builder, bool enabled = true) + { + ArgumentNullException.ThrowIfNull(builder); + GetOrAddDenoAnnotation(builder).AllowAll = enabled; + return builder; + } + + // ---- Granular permissions --------------------------------------------------------------- + + /// Grants a Deno permission, optionally scoped to the supplied values. + /// The Deno app resource builder. + /// The permission to grant. + /// Optional values that scope the permission. When empty, all access of the selected kind is allowed. + /// A reference to the . + /// Thrown when contains a null or empty value, or a value containing a comma. + /// Thrown when is not a defined value. + /// The resource builder. + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoAllow(this IResourceBuilder builder, DenoPermissionKind kind, params string[] values) + => AddDenoPermission(builder, kind, deny: false, values); + + /// Denies a Deno permission, optionally scoped to the supplied values. + /// The Deno app resource builder. + /// The permission to deny. + /// Optional values that scope the permission. When empty, all access of the selected kind is denied. + /// A reference to the . + /// Thrown when contains a null or empty value, or a value containing a comma. + /// Thrown when is not a defined value. + /// The resource builder. + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoDeny(this IResourceBuilder builder, DenoPermissionKind kind, params string[] values) + => AddDenoPermission(builder, kind, deny: true, values); + + // ---- Config / resolution flags ---------------------------------------------------------- + + /// Sets --config <file> (path to a deno.json/deno.jsonc). + /// The Deno app resource builder. + /// The Deno configuration file path. + /// A reference to the . + /// The resource builder. + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoConfig(this IResourceBuilder builder, string configFile) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(configFile); + GetOrAddDenoAnnotation(builder).ConfigFile = configFile; + return builder; + } + + /// Sets --import-map <file>. + /// The Deno app resource builder. + /// The import map file path. + /// A reference to the . + /// The resource builder. + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoImportMap(this IResourceBuilder builder, string importMapFile) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(importMapFile); + GetOrAddDenoAnnotation(builder).ImportMap = importMapFile; + return builder; + } + + /// Sets --lock <file>. + /// The Deno app resource builder. + /// The lockfile path. + /// A reference to the . + /// The resource builder. + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoLock(this IResourceBuilder builder, string lockFile) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(lockFile); + var annotation = GetOrAddDenoAnnotation(builder); + annotation.Lock = lockFile; + annotation.NoLock = false; + return builder; + } + + /// Sets --no-lock, disabling lockfile use. + /// The Deno app resource builder. + /// A reference to the . + /// The resource builder. + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoNoLock(this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var annotation = GetOrAddDenoAnnotation(builder); + annotation.NoLock = true; + annotation.Lock = null; + return builder; + } + + /// + /// Sets --node-modules-dir, optionally with a mode emitted as + /// --node-modules-dir=<mode>. + /// + /// The Deno app resource builder. + /// The node_modules mode. When , emits --node-modules-dir without a value. + /// A reference to the . + /// Thrown when is not a defined value. + /// The resource builder. + /// + /// The generated Deno Dockerfile publisher does not support manual mode because it excludes local + /// node_modules from the build context. Use auto or provide a custom Dockerfile for that mode. + /// + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoNodeModulesDir(this IResourceBuilder builder, DenoNodeModulesDirMode? mode = null) + { + ArgumentNullException.ThrowIfNull(builder); + if (mode is not null && !Enum.IsDefined(mode.Value)) + { + throw new ArgumentOutOfRangeException(nameof(mode), mode, "The node_modules mode must be a defined DenoNodeModulesDirMode value."); + } + + var annotation = GetOrAddDenoAnnotation(builder); + annotation.NodeModulesDirSet = true; + annotation.NodeModulesDirMode = mode; + return builder; + } + + // ---- Unstable flags --------------------------------------------------------------------- + + /// + /// Adds one or more --unstable-* flags. Each feature may be supplied bare (for example "kv", + /// "worker-options", "sloppy-imports") or fully qualified ("--unstable-kv"). + /// + /// The Deno app resource builder. + /// The unstable feature names or fully-qualified --unstable-* flags to emit. + /// A reference to the . + /// The resource builder. + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoUnstable(this IResourceBuilder builder, params string[] features) + { + ArgumentNullException.ThrowIfNull(builder); + var annotation = GetOrAddDenoAnnotation(builder); + foreach (var feature in features ?? []) + { + if (string.IsNullOrEmpty(feature)) + { + continue; + } + + if (feature.StartsWith("--", StringComparison.Ordinal) && + !feature.StartsWith("--unstable-", StringComparison.Ordinal)) + { + throw new ArgumentException("Qualified Deno unstable flags must start with \"--unstable-\".", nameof(features)); + } + + annotation.UnstableFlags.Add(feature.StartsWith("--unstable-", StringComparison.Ordinal) ? feature : $"--unstable-{feature}"); + } + + return builder; + } + + // ---- Watch / inspect -------------------------------------------------------------------- + + /// Enables --watch (or --watch-hmr when is ). + /// The Deno app resource builder. + /// Whether to emit --watch-hmr instead of --watch. + /// A reference to the . + /// The resource builder. + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoWatch(this IResourceBuilder builder, bool hmr = false) + { + ArgumentNullException.ThrowIfNull(builder); + var annotation = GetOrAddDenoAnnotation(builder); + if (hmr) + { + annotation.WatchHmr = true; + annotation.Watch = false; + } + else + { + annotation.Watch = true; + annotation.WatchHmr = false; + } + + return builder; + } + + /// Enables a Deno inspector mode, optionally at (for example 127.0.0.1:9229). + /// The Deno app resource builder. + /// The inspector mode to enable. + /// The optional inspector host:port value. + /// A reference to the . + /// Thrown when is not a defined value. + /// The resource builder. + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoInspect( + this IResourceBuilder builder, + DenoInspectMode mode = DenoInspectMode.Inspect, + string? hostPort = null) + { + ArgumentNullException.ThrowIfNull(builder); + if (!Enum.IsDefined(mode)) + { + throw new ArgumentOutOfRangeException(nameof(mode), mode, "The inspect mode must be a defined DenoInspectMode value."); + } + + var annotation = GetOrAddDenoAnnotation(builder); + annotation.Inspect = mode; + annotation.InspectHostPort = string.IsNullOrEmpty(hostPort) ? null : hostPort; + return builder; + } + + // ---- Modes ------------------------------------------------------------------------------ + + /// Selects the deno run <entrypoint> mode (the default). + /// The Deno app resource builder. + /// A reference to the . + /// The resource builder. + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoRun(this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var annotation = GetOrAddDenoAnnotation(builder); + annotation.Mode = DenoCommandMode.Run; + annotation.ModeSet = true; + annotation.TaskName = null; + RemoveDenoServeEndpoint(builder, annotation); + return builder; + } + + /// + /// Selects the deno task <taskName> mode, running a task defined in deno.json instead of a + /// script entrypoint. Permissions are defined by the task itself and are not emitted for this mode. + /// + /// The Deno app resource builder. + /// The name of the task in deno.json to run. + /// A reference to the . + /// The resource builder. + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoTask(this IResourceBuilder builder, string taskName) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(taskName); + var annotation = GetOrAddDenoAnnotation(builder); + annotation.Mode = DenoCommandMode.Task; + annotation.ModeSet = true; + annotation.TaskName = taskName; + RemoveDenoServeEndpoint(builder, annotation); + return builder; + } + + /// + /// Restores the endpoint state changed by when + /// a later mode selector wins, so only deno serve publishes an HTTP binding or injects PORT. + /// + /// + /// An endpoint created by WithDenoServe is removed. A same-name endpoint supplied by the caller is + /// retained and its prior environment-variable and target-port configuration is restored. The exact + /// environment callback added by WithDenoServe is also removed; leaving that callback behind would wait + /// forever for allocation of an endpoint that no longer exists. + /// + private static void RemoveDenoServeEndpoint(IResourceBuilder builder, DenoCommandLineAnnotation annotation) + { + if (annotation.ServeEndpoint is { } endpoint) + { + if (annotation.ServeEnvironmentCallback is { } environmentCallback) + { + builder.Resource.Annotations.Remove(environmentCallback); + } + + if (annotation.ServeEndpointCreated) + { + builder.Resource.Annotations.Remove(endpoint); + } + else if (annotation.ServeAssignedTargetPort is { } assignedTargetPort && + endpoint.TargetPort == assignedTargetPort) + { + endpoint.TargetPort = null; + } + + annotation.ServeEndpoint = null; + annotation.ServeEndpointCreated = false; + annotation.ServeEnvironmentCallback = null; + annotation.ServeAssignedTargetPort = null; + } + } + + /// Selects the deno serve <entrypoint> mode for serving an HTTP entrypoint. + /// The Deno app resource builder. + /// A reference to the . + /// The resource builder. + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoServe(this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var annotation = GetOrAddDenoAnnotation(builder); + annotation.Mode = DenoCommandMode.Serve; + annotation.ModeSet = true; + + if (annotation.ServeEndpoint is not null) + { + return builder; + } + + var existingEndpoint = builder.Resource.Annotations + .OfType() + .FirstOrDefault(e => string.Equals(e.Name, "http", StringComparison.OrdinalIgnoreCase)); + + if (existingEndpoint is null) + { + builder.WithHttpEndpoint(); + } + + var serveEndpoint = builder.Resource.Annotations + .OfType() + .First(e => string.Equals(e.Name, "http", StringComparison.OrdinalIgnoreCase)); + var endpointReference = new EndpointReference( + builder.Resource, + serveEndpoint, + KnownNetworkIdentifiers.LocalhostNetwork); + var environmentCallback = new EnvironmentCallbackAnnotation(context => + { + context.EnvironmentVariables["PORT"] = endpointReference.Property(EndpointProperty.TargetPort); + }); + builder.WithAnnotation(environmentCallback); + + annotation.ServeEndpoint = serveEndpoint; + annotation.ServeEndpointCreated = existingEndpoint is null; + annotation.ServeEnvironmentCallback = environmentCallback; + + if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + builder.WithEndpoint("http", e => + { + if (e.TargetPort is null) + { + // Target ports are private to each process/container. Reusing Deno's conventional + // port is deterministic and leaves host-port uniqueness to Aspire's allocator. + annotation.ServeAssignedTargetPort = DenoServeDefaultPort; + e.TargetPort = annotation.ServeAssignedTargetPort; + } + }, createIfNotExists: false); + } + + return builder; + } + + // ---- Script / raw args ------------------------------------------------------------------ + + /// + /// Appends arguments passed to the script AFTER the entrypoint. Deno forwards everything after the entrypoint + /// to the running program. + /// + /// The Deno app resource builder. + /// The script arguments to append after the entrypoint or task name. + /// A reference to the . + /// The resource builder. + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoScriptArgs(this IResourceBuilder builder, params string[] args) + { + ArgumentNullException.ThrowIfNull(builder); + var annotation = GetOrAddDenoAnnotation(builder); + annotation.ScriptArgs.AddRange(args ?? []); + return builder; + } + + /// + /// Appends raw runtime arguments injected verbatim BEFORE the entrypoint. This is the escape hatch that gives + /// full parity with AddExecutable("name", "deno", workdir, args...) for any flag not covered by a + /// dedicated WithDeno* method. + /// + /// The Deno app resource builder. + /// The runtime arguments to append before the entrypoint or task name. + /// A reference to the . + /// The resource builder. + [AspireExport] + [Experimental("ASPIREDENO001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDenoRuntimeArgs(this IResourceBuilder builder, params string[] args) + { + ArgumentNullException.ThrowIfNull(builder); + var annotation = GetOrAddDenoAnnotation(builder); + annotation.RuntimeArgs.AddRange(args ?? []); + return builder; + } + + // ---- Arg builder ------------------------------------------------------------------------ + + /// + /// Builds the ordered Deno argument list (excluding the deno executable itself) from a command-line + /// annotation. Runtime flags precede the entrypoint; script args follow it, matching valid Deno CLI order. + /// + private static List BuildDenoArgs( + DenoCommandLineAnnotation deno, + string scriptPath, + DenoServeEndpointArguments? serveEndpointArguments = null, + bool includeDevelopmentFlags = true, + bool includeCachedOnly = false, + bool usePublishDefaultPermissions = false, + JavaScriptRunScriptAnnotation? runScript = null, + JavaScriptPackageManagerAnnotation? packageManager = null) + { + var args = new List(); + + // Task mode resolves flags from deno.json and never emits permissions, import map, or the + // development-only watch/inspect flags, so the conflict surface differs from run/serve. + var isTaskMode = deno.Mode == DenoCommandMode.Task || + (deno.Mode != DenoCommandMode.Serve && runScript is not null && packageManager?.ScriptCommand == "task" && !deno.ModeSet); + + ThrowIfRuntimeArgsConflictWithManagedFlags( + deno, + emitsServeEndpoint: deno.Mode == DenoCommandMode.Serve && serveEndpointArguments is not null, + includeImportMap: !isTaskMode, + includeDevelopmentFlags: includeDevelopmentFlags && !isTaskMode); + + switch (deno.Mode) + { + case DenoCommandMode.Task: + args.Add("task"); + // Task-level permissions live in deno.json. Deno 2.5.6 also rejects `deno task --import-map ...`, + // while still accepting config and dependency-management flags such as --lock and --node-modules-dir. + AppendTaskResolutionFlags(args, deno); + AppendUnstableFlags(args, deno); + args.AddRange(deno.RuntimeArgs); + args.Add(deno.TaskName ?? scriptPath); + args.AddRange(deno.ScriptArgs); + return args; + + case DenoCommandMode.Serve: + args.Add("serve"); + break; + + case DenoCommandMode.Run: + default: + if (runScript is not null && + packageManager?.ScriptCommand == "task" && + !deno.ModeSet) + { + args.Add("task"); + AppendTaskResolutionFlags(args, deno); + AppendUnstableFlags(args, deno); + args.AddRange(deno.RuntimeArgs); + args.Add(runScript.ScriptName); + args.AddRange(runScript.Args); + args.AddRange(deno.ScriptArgs); + return args; + } + + args.Add("run"); + break; + } + + AppendPermissionFlags(args, deno, usePublishDefaultPermissions); + AppendResolutionFlags(args, deno); + if (includeCachedOnly && !RuntimeArgsSelectCachePolicy(deno.RuntimeArgs)) + { + args.Add("--cached-only"); + } + + AppendUnstableFlags(args, deno); + if (includeDevelopmentFlags) + { + AppendWatchFlags(args, deno); + AppendInspectFlags(args, deno); + } + + if (deno.Mode == DenoCommandMode.Serve && serveEndpointArguments is not null) + { + args.Add("--host"); + args.Add(serveEndpointArguments.Host); + args.Add("--port"); + args.Add(serveEndpointArguments.Port); + } + + args.AddRange(deno.RuntimeArgs); + + args.Add(scriptPath); + args.AddRange(deno.ScriptArgs); + return args; + } + + private static void AppendPermissionFlags(List args, DenoCommandLineAnnotation deno, bool usePublishDefaultPermissions) + { + var hasGranularAllow = deno.Permissions.Any(p => !p.Deny); + + // Keep local execution permissive for parity with Node/Bun, but default published images to only + // network and environment access. Deny-only configuration narrows that publish-safe baseline; it must + // not switch the baseline to -A and broaden access merely because a deny flag was added. + if (usePublishDefaultPermissions && deno.AllowAll is null && !hasGranularAllow) + { + args.Add("--allow-net"); + args.Add("--allow-env"); + + foreach (var permission in OrderPermissions(deno.Permissions).Where(p => p.Deny)) + { + args.Add(FormatPermission(permission)); + } + + return; + } + + // Default (AllowAll == null): grant -A only when the caller has not opted into any granular allow flag. + var emitAllowAll = deno.AllowAll ?? !hasGranularAllow; + if (emitAllowAll) + { + args.Add("-A"); + // -A subsumes granular allows; only deny flags meaningfully narrow it. + foreach (var permission in OrderPermissions(deno.Permissions).Where(p => p.Deny)) + { + args.Add(FormatPermission(permission)); + } + + return; + } + + foreach (var permission in OrderPermissions(deno.Permissions)) + { + args.Add(FormatPermission(permission)); + } + } + + // Deterministic, valid-CLI ordering independent of fluent call order: by permission category, allow before deny. + private static IEnumerable OrderPermissions(IEnumerable permissions) + => permissions.OrderBy(p => (int)p.Kind).ThenBy(p => p.Deny ? 1 : 0); + + private static string FormatPermission(DenoPermission permission) + { + var prefix = permission.Deny ? "--deny-" : "--allow-"; + return permission.Values.Count == 0 + ? $"{prefix}{permission.Name}" + : $"{prefix}{permission.Name}={string.Join(",", permission.Values)}"; + } + + private static void AppendResolutionFlags(List args, DenoCommandLineAnnotation deno) + { + args.AddRange(GetResolutionFlags(deno)); + } + + private static void AppendTaskResolutionFlags(List args, DenoCommandLineAnnotation deno) + { + args.AddRange(GetResolutionFlags(deno, includeImportMap: false)); + } + + private static IEnumerable GetResolutionFlags(DenoCommandLineAnnotation deno) + => GetResolutionFlags(deno, includeImportMap: true); + + private static IEnumerable GetResolutionFlags(DenoCommandLineAnnotation deno, bool includeImportMap) + { + if (!string.IsNullOrEmpty(deno.ConfigFile)) + { + yield return "--config"; + yield return deno.ConfigFile; + } + + if (includeImportMap && !string.IsNullOrEmpty(deno.ImportMap)) + { + yield return "--import-map"; + yield return deno.ImportMap; + } + + if (deno.NoLock) + { + yield return "--no-lock"; + } + else if (!string.IsNullOrEmpty(deno.Lock)) + { + yield return "--lock"; + yield return deno.Lock; + } + + if (deno.NodeModulesDirSet) + { + yield return deno.NodeModulesDirMode is not { } mode + ? "--node-modules-dir" + : $"--node-modules-dir={GetDenoNodeModulesDirModeValue(mode)}"; + } + } + + private static string GetDenoNodeModulesDirModeValue(DenoNodeModulesDirMode mode) => mode switch + { + DenoNodeModulesDirMode.None => "none", + DenoNodeModulesDirMode.Auto => "auto", + DenoNodeModulesDirMode.Manual => "manual", + _ => throw new InvalidOperationException($"Unsupported Deno node_modules mode '{mode}'."), + }; + + private static void AppendUnstableFlags(List args, DenoCommandLineAnnotation deno) + { + foreach (var flag in deno.UnstableFlags) + { + args.Add(flag); + } + } + + /// + /// Rejects entries that + /// collide with a flag Aspire already emits for this resource. + /// + /// + /// Verified against Deno 2.9.0: single-occurrence options fail with + /// error: the argument '--config <FILE>' cannot be used multiple times, and mutually exclusive + /// pairs (--config with --no-config, --no-lock with --lock, + /// --watch with --watch-hmr) fail with cannot be used with. Both are clap errors that + /// never mention Aspire, so the resource simply fails to start with nothing pointing at the knob that caused it. + /// + /// Repeatable options are deliberately absent from this check. Deno merges --allow-read=/tmp with + /// --allow-read=/var and accepts -A alongside --allow-all, so layering extra grants + /// over the managed ones is legitimate and must keep working. + /// + /// + private static void ThrowIfRuntimeArgsConflictWithManagedFlags( + DenoCommandLineAnnotation deno, + bool emitsServeEndpoint, + bool includeImportMap, + bool includeDevelopmentFlags) + { + foreach (var arg in deno.RuntimeArgs) + { + // Both spellings reach Deno's parser: "--port 3000" (separate value) and "--port=3000". + var name = arg.AsSpan(); + var separator = name.IndexOf('='); + if (separator >= 0) + { + name = name[..separator]; + } + + if (GetManagedDenoFlagConflict(name, deno, emitsServeEndpoint, includeImportMap, includeDevelopmentFlags) is not { } conflict) + { + continue; + } + + throw new InvalidOperationException( + $"The argument '{arg}' cannot be configured with {nameof(WithDenoRuntimeArgs)} because {conflict.Source} already emits {conflict.ManagedFlag}, and Deno rejects those arguments when they are combined. {conflict.Remedy}"); + } + } + + private static (string ManagedFlag, string Source, string Remedy)? GetManagedDenoFlagConflict( + ReadOnlySpan name, + DenoCommandLineAnnotation deno, + bool emitsServeEndpoint, + bool includeImportMap, + bool includeDevelopmentFlags) + { + if (emitsServeEndpoint && (name.Equals("--host", StringComparison.Ordinal) || name.Equals("--port", StringComparison.Ordinal))) + { + return ("--host and --port from the resource's endpoint", nameof(WithDenoServe), "Configure the endpoint instead, for example WithHttpEndpoint(port: 5005)."); + } + + // -c is an alias for --config, while --no-config is mutually exclusive with it. + if (!string.IsNullOrEmpty(deno.ConfigFile) && + (name.Equals("--config", StringComparison.Ordinal) || + name.Equals("-c", StringComparison.Ordinal) || + name.Equals("--no-config", StringComparison.Ordinal))) + { + return ("--config", nameof(WithDenoConfig), $"Pass the configuration file to {nameof(WithDenoConfig)} instead."); + } + + if (includeImportMap && !string.IsNullOrEmpty(deno.ImportMap) && name.Equals("--import-map", StringComparison.Ordinal)) + { + return ("--import-map", nameof(WithDenoImportMap), $"Pass the import map to {nameof(WithDenoImportMap)} instead."); + } + + // --no-lock and --lock are mutually exclusive, so either managed spelling conflicts with either raw one. + if ((deno.NoLock || !string.IsNullOrEmpty(deno.Lock)) && + (name.Equals("--lock", StringComparison.Ordinal) || name.Equals("--no-lock", StringComparison.Ordinal))) + { + var managedFlag = deno.NoLock ? "--no-lock" : "--lock"; + var source = deno.NoLock ? nameof(WithDenoNoLock) : nameof(WithDenoLock); + return (managedFlag, source, $"Configure locking with {source} instead."); + } + + if (deno.NodeModulesDirSet && name.Equals("--node-modules-dir", StringComparison.Ordinal)) + { + return ("--node-modules-dir", nameof(WithDenoNodeModulesDir), $"Pass the mode to {nameof(WithDenoNodeModulesDir)} instead."); + } + + if (!includeDevelopmentFlags) + { + return null; + } + + if ((deno.Watch || deno.WatchHmr) && + (name.Equals("--watch", StringComparison.Ordinal) || name.Equals("--watch-hmr", StringComparison.Ordinal))) + { + var managedFlag = deno.WatchHmr ? "--watch-hmr" : "--watch"; + return (managedFlag, nameof(WithDenoWatch), $"Select the watch mode with {nameof(WithDenoWatch)} instead."); + } + + if (deno.Inspect is { } inspectMode && name.StartsWith("--inspect", StringComparison.Ordinal)) + { + var managedFlag = inspectMode switch + { + DenoInspectMode.InspectBrk => "--inspect-brk", + DenoInspectMode.InspectWait => "--inspect-wait", + _ => "--inspect", + }; + + return (managedFlag, nameof(WithDenoInspect), $"Configure the inspector with {nameof(WithDenoInspect)} instead."); + } + + return null; + } + + /// + /// Reports whether the caller already selected a module cache policy through + /// . + /// + /// + /// --cached-only is an Aspire default (published images pre-populate DENO_DIR, so a cold + /// network fetch at startup indicates a broken image) rather than a hard requirement. Deno accepts + /// --cached-only --reload without error but --cached-only silently wins, verified on 2.9.0 + /// against a real jsr: import: a cold cache fails identically with and without --reload. + /// Emitting both would therefore turn an explicit caller instruction into a no-op, so drop the default + /// instead of overriding the caller. + /// + private static bool RuntimeArgsSelectCachePolicy(IEnumerable runtimeArgs) + { + foreach (var arg in runtimeArgs) + { + var name = arg.AsSpan(); + var separator = name.IndexOf('='); + if (separator >= 0) + { + name = name[..separator]; + } + + if (name.Equals("--reload", StringComparison.Ordinal) || + name.Equals("-r", StringComparison.Ordinal) || + name.Equals("--cached-only", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static void AppendWatchFlags(List args, DenoCommandLineAnnotation deno) + { + // Deno rejects "--watch-hmr" combined with "--watch", so these must stay mutually exclusive. + // WithDenoWatch already clears the other flag; the else-if keeps that invariant local to the emitter. + if (deno.WatchHmr) + { + args.Add("--watch-hmr"); + } + else if (deno.Watch) + { + args.Add("--watch"); + } + } + + private static void AppendInspectFlags(List args, DenoCommandLineAnnotation deno) + { + if (deno.Inspect is not { } mode) + { + return; + } + + var flag = mode switch + { + DenoInspectMode.InspectBrk => "--inspect-brk", + DenoInspectMode.InspectWait => "--inspect-wait", + _ => "--inspect", + }; + + args.Add(string.IsNullOrEmpty(deno.InspectHostPort) ? flag : $"{flag}={deno.InspectHostPort}"); + } + + /// + /// Builds the container entrypoint array (deno plus args). Honors publish-safe command-line flags from + /// the explicit Deno annotation, excluding development-only watch and inspector flags. + /// + private static string[] BuildDenoEntrypoint(IResource resource, string command, string scriptPath) + { + if (resource.TryGetLastAnnotation(out var publishMode) && + publishMode.Mode == JavaScriptPublishMode.PackageScript) + { + var packageScriptManager = resource.TryGetLastAnnotation(out var packageScriptManagerAnnotation) + ? packageScriptManagerAnnotation + : throw new InvalidOperationException("PublishAsPackageScript requires a Deno package manager. Add a deno.json file or call WithDeno()."); + + return BuildDenoPackageScriptEntrypoint( + packageScriptManager.ExecutableName, + packageScriptManager.ScriptCommand ?? "task", + publishMode.ScriptName!, + publishMode.RunScriptArguments); + } + + var entrypoint = new List { command }; + var deno = resource.TryGetLastAnnotation(out var denoAnnotation) ? denoAnnotation : null; + var runScript = resource.TryGetLastAnnotation(out var runScriptAnnotation) ? runScriptAnnotation : null; + var packageManager = resource.TryGetLastAnnotation(out var packageManagerAnnotation) ? packageManagerAnnotation : null; + var containerScriptPath = ToDenoContainerPath(scriptPath); + + if (deno is not null) + { + var serveEndpointArguments = deno.Mode == DenoCommandMode.Serve + ? GetDenoServeEndpointArguments(resource, isPublishMode: true, useLiteralTargetPort: true) + : null; + entrypoint.AddRange(BuildDenoArgs( + deno, + containerScriptPath, + serveEndpointArguments, + includeDevelopmentFlags: false, + includeCachedOnly: deno.Mode != DenoCommandMode.Task, + usePublishDefaultPermissions: true, + runScript: runScript, + packageManager: packageManager).Cast()); + } + else if (runScript is not null && packageManager?.ScriptCommand == "task") + { + entrypoint.Add("task"); + entrypoint.Add(runScript.ScriptName); + entrypoint.AddRange(runScript.Args); + } + else + { + entrypoint.Add("run"); + entrypoint.Add("--allow-net"); + entrypoint.Add("--allow-env"); + entrypoint.Add("--cached-only"); + entrypoint.Add(containerScriptPath); + } + + NormalizeDenoContainerPathArguments(entrypoint); + return [.. entrypoint]; + } + + private static void ThrowIfUnsupportedDenoDockerfileOptions(IResource resource) + { + if (resource.TryGetLastAnnotation(out var publishMode) && + publishMode.Mode is JavaScriptPublishMode.StaticWebsite or JavaScriptPublishMode.NodeServer) + { + var publishMethod = publishMode.Mode == JavaScriptPublishMode.StaticWebsite + ? nameof(PublishAsStaticWebsite) + : nameof(PublishAsNodeServer); + throw new InvalidOperationException($"Generated Deno Dockerfiles do not support {publishMethod}. Use AddJavaScriptApp(...).WithDeno() or provide a custom Dockerfile."); + } + + if (resource.TryGetLastAnnotation(out var packageManager) && + !string.Equals(packageManager.ExecutableName, "deno", StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Generated Deno Dockerfiles do not support alternate package manager '{packageManager.ExecutableName}'. Use WithDeno() or provide a custom Dockerfile."); + } + + if (resource.TryGetLastAnnotation(out var deno) && + deno.NodeModulesDirSet && + deno.NodeModulesDirMode == DenoNodeModulesDirMode.Manual) + { + throw new InvalidOperationException("The 'manual' node_modules mode is not supported by generated Deno Dockerfiles because node_modules is excluded from the build context. Use the 'auto' mode or provide a custom Dockerfile."); + } + + if (deno is not null) + { + if (deno.RuntimeArgs.Any(argument => + argument == "--env-file" || + argument.StartsWith("--env-file=", StringComparison.Ordinal))) + { + throw new InvalidOperationException( + "Generated Deno Dockerfiles do not support '--env-file' because dotenv files can contain secrets that would be copied into the container image. Use Aspire environment variables or secret parameters, or provide a custom Dockerfile that handles the file securely."); + } + + // The Docker build context is the app directory, so a path that is absolute or escapes the app + // directory is never copied into the image and would break both `deno cache` and the entrypoint. + ThrowIfPathEscapesDenoBuildContext(deno.ConfigFile, nameof(WithDenoConfig)); + ThrowIfPathEscapesDenoBuildContext(deno.ImportMap, nameof(WithDenoImportMap)); + ThrowIfPathEscapesDenoBuildContext(deno.Lock, nameof(WithDenoLock)); + } + } + + /// + /// Rejects a configured path that would resolve outside the generated Dockerfile's build context. + /// + /// + /// Validation uses the same platform-independent normalizer as the generated Dockerfile. Both / and + /// \ are treated as separators so Windows rooted and UNC paths cannot become absolute only after they + /// are emitted into the Linux container. Traversal is resolved by depth: config/../deno.json stays + /// inside the context and normalizes to deno.json, while config/../../outside.json escapes it. + /// + private static void ThrowIfPathEscapesDenoBuildContext(string? path, string methodName) + { + if (string.IsNullOrEmpty(path)) + { + return; + } + + if (!TryNormalizeDenoContainerRelativePath(path, out _)) + { + throw new InvalidOperationException($"The path '{path}' configured with {methodName} is outside the Deno application directory, so it is not part of the generated Dockerfile's build context. Move the file inside the application directory or provide a custom Dockerfile."); + } + } + + private static bool TryNormalizeDenoContainerRelativePath(string path, out string normalizedPath) + { + var containerPath = path.Replace('\\', '/'); + if (containerPath.StartsWith('/') || IsWindowsDriveQualifiedPath(containerPath)) + { + normalizedPath = string.Empty; + return false; + } + + // Deno accepts remote import maps. They are not build-context paths and must retain the URI's double slash. + if (Uri.TryCreate(containerPath, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + normalizedPath = containerPath; + return true; + } + + var normalizedSegments = new List(); + foreach (var segment in containerPath.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + if (segment == ".") + { + continue; + } + + if (segment == "..") + { + if (normalizedSegments.Count == 0) + { + normalizedPath = string.Empty; + return false; + } + + normalizedSegments.RemoveAt(normalizedSegments.Count - 1); + continue; + } + + normalizedSegments.Add(segment); + } + + normalizedPath = string.Join('/', normalizedSegments); + return true; + } + + /// + /// Rejects Deno-specific command-line options when a non-Deno package manager is the effective launcher. + /// The WithDeno* flags produce a Deno argument vector (for example run -A --watch main.ts), + /// which is meaningless once the command is switched to another package manager such as npm. + /// + private static void ThrowIfDenoOptionsConflictWithPackageManager(IResource resource) + { + if (resource.TryGetLastAnnotation(out _) && + resource.TryGetLastAnnotation(out var packageManager) && + !string.Equals(packageManager.ExecutableName, "deno", StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Deno command-line options configured with the WithDeno* methods cannot be combined with package manager '{packageManager.ExecutableName}' on resource '{resource.Name}'. Remove the WithDeno* options or use WithDeno()."); + } + } + + /// + /// Converts a host-relative path to normalized POSIX form for the generated Linux container stages. + /// + /// + /// AppHost-configured paths use the host separator, so on Windows a nested entrypoint is configured as + /// src\main.ts. Emitting that verbatim into deno cache or ENTRYPOINT makes Linux treat + /// the whole string as a single file name and the container fails to start. + /// + private static string ToDenoContainerPath(string path) + => TryNormalizeDenoContainerRelativePath(path, out var normalizedPath) + ? normalizedPath + : path.Replace('\\', '/'); + + // Deno options that Aspire emits as a separate flag/value pair where the value is a path that must be + // rewritten to its container form. + private static readonly string[] s_denoContainerPathFlags = ["--cert", "--config", "-c", "--import-map", "--lock"]; + private static readonly string[] s_denoContainerPathListFlags = + ["--allow-read", "--deny-read", "--allow-write", "--deny-write", "--allow-ffi", "--deny-ffi"]; + + private static void NormalizeDenoContainerPathArguments(List args) + { + for (var index = 0; index < args.Count; index++) + { + var argument = args[index]; + var separator = argument.IndexOf('='); + var flag = separator >= 0 ? argument[..separator] : argument; + if (separator >= 0 && Array.IndexOf(s_denoContainerPathListFlags, flag) >= 0) + { + var normalizedValues = argument[(separator + 1)..] + .Split(',') + .Select(ToDenoContainerPath); + args[index] = $"{flag}={string.Join(',', normalizedValues)}"; + continue; + } + + if (Array.IndexOf(s_denoContainerPathFlags, flag) < 0) + { + continue; + } + + if (separator >= 0) + { + args[index] = $"{flag}={ToDenoContainerPath(argument[(separator + 1)..])}"; + } + else if (index + 1 < args.Count) + { + args[index + 1] = ToDenoContainerPath(args[index + 1]); + index++; + } + } + } + + // Raw runtime flags that "deno cache" accepts AND that govern module resolution, so omitting them from the + // build-time cache step changes what gets downloaded (or whether the download can happen at all). + // + // Verified against Deno 2.9.0 by running "deno cache m.ts": permission, inspector, watch, and + // --cached-only flags are rejected outright ("error: unexpected argument"), so the forwarding set has to be an + // allowlist. Forwarding deno.RuntimeArgs wholesale would break "docker build" for the very common "-A". + // + // Two behaviors that shape the split below, both verified on 2.9.0: + // * --frozen fails with "the argument '--frozen[=]' cannot be used multiple times", so flags that + // BuildDenoCacheCommand already emits are never forwarded. Raw duplicates of the managed resolution flags + // (--config/--import-map/--lock/--no-lock/--node-modules-dir) are unreachable here because + // ThrowIfRuntimeArgsConflictWithManagedFlags rejects them earlier whenever the managed setter was used. + // * Despite "--lock []" being documented as an optional value, clap consumes the following token: + // "deno cache --lock m.ts" fails with "the following required arguments were not provided: ...". + // A bare trailing --lock would therefore swallow the entrypoint, so it is only forwarded with a value. + private static readonly string[] s_denoCacheValueFlags = + ["--cert", "--conditions", "--config", "-c", "--import-map", "--lock", "--minimum-dependency-age"]; + + private static readonly string[] s_denoCacheStandaloneFlags = + [ + "--no-remote", "--no-npm", "--no-config", "--no-lock", "--vendor", "--allow-import", "-I", + "--deny-import", "--allow-scripts", "--node-modules-dir", "--node-modules-linker", "--env-file" + ]; + + private static IEnumerable GetCacheCompatibleRuntimeArgs(List runtimeArgs) + { + for (var index = 0; index < runtimeArgs.Count; index++) + { + var arg = runtimeArgs[index]; + var name = arg.AsSpan(); + var separator = name.IndexOf('='); + var hasInlineValue = separator >= 0; + if (hasInlineValue) + { + name = name[..separator]; + } + + var nameText = name.ToString(); + if (Array.IndexOf(s_denoCacheStandaloneFlags, nameText) >= 0) + { + yield return arg; + continue; + } + + if (Array.IndexOf(s_denoCacheValueFlags, nameText) < 0) + { + // Anything else is a run-time concern (permissions, inspector, watch) that "deno cache" rejects, + // or a bare value belonging to such a flag. Either way it must not reach the cache command. + continue; + } + + if (hasInlineValue) + { + yield return arg; + continue; + } + + // Only forward the space-separated spelling when the value is actually present, so a trailing flag + // cannot consume the entrypoint that BuildDenoCacheCommand appends after these arguments. + if (index + 1 < runtimeArgs.Count && !runtimeArgs[index + 1].StartsWith('-')) + { + yield return arg; + yield return runtimeArgs[index + 1]; + index++; + } + } + } + + private static string BuildDenoCacheCommand(IResource resource, string scriptPath, string workingDirectory) + { + var args = new List { "deno", "cache" }; + var hasRunScript = resource.TryGetLastAnnotation(out _) && + resource.TryGetLastAnnotation(out var packageManager) && + packageManager.ScriptCommand == "task"; + + if (resource.TryGetLastAnnotation(out var deno)) + { + var isTaskMode = deno.Mode == DenoCommandMode.Task || (hasRunScript && deno.Mode == DenoCommandMode.Run && !deno.ModeSet); + if (isTaskMode) + { + return "mkdir -p /deno-dir"; + } + + args.AddRange(GetResolutionFlags(deno)); + args.AddRange(GetCacheCompatibleRuntimeArgs(deno.RuntimeArgs)); + args.AddRange(deno.UnstableFlags); + if (ShouldUseFrozenLock(deno, workingDirectory)) + { + args.Add("--frozen"); + } + } + else if (hasRunScript) + { + return "mkdir -p /deno-dir"; + } + else if (File.Exists(Path.Combine(workingDirectory, "deno.lock"))) + { + args.Add("--frozen"); + } + + args.Add(ToDenoContainerPath(scriptPath)); + NormalizeDenoContainerPathArguments(args); + return JoinDockerShellCommand(args); + } + + /// + /// Builds the ENTRYPOINT for a Deno package-script container. + /// + /// + /// Exec form is preferred because Deno runtime images can be shell-less (for example + /// denoland/deno:2.1-distroless), where a ["sh", "-c", ...] entrypoint fails to start. + /// Arguments that rely on the shell (for example "-- --port $PORT") cannot be expressed in exec + /// form, so those keep the shell entrypoint and therefore require a shell-capable runtime image. + /// + internal static string[] BuildDenoPackageScriptEntrypoint(string executableName, string scriptCommand, string scriptName, string? runScriptArguments) + { + if (RequiresShellForDenoRunScriptArguments(runScriptArguments)) + { + // Only runScriptArguments is meant to be shell-evaluated. The command itself is fixed data + // (a task name can legitimately contain spaces, e.g. "build prod"), so quote those parts or + // the shell would word-split them into a different command. + var runCommand = $"{QuoteDockerShellArgument(executableName)} {QuoteDockerShellArgument(scriptCommand)} {QuoteDockerShellArgument(scriptName)} {runScriptArguments}"; + return ["sh", "-c", $"exec {runCommand}"]; + } + + List entrypoint = [executableName, scriptCommand, scriptName]; + entrypoint.AddRange(TokenizeDenoRunScriptArguments(runScriptArguments)); + return [.. entrypoint]; + } + + // Exec form performs no shell interpretation, so anything that depends on the shell - variable + // expansion, command substitution, globbing, redirection, or operators - must keep the `sh -c` form. + // + // This is deliberately an allowlist of characters the tokenizer reproduces faithfully rather than a + // denylist of shell metacharacters. A denylist fails open: any character nobody thought to enumerate + // is silently assumed inert and gets baked into exec form with different semantics. Real cases that a + // metacharacter denylist missed here: `[ab].ts` (bracket expression), `#1` (comment - the rest of the + // line is discarded by the shell), `{a,b}.ts` (brace expansion on bash/ash though not dash), and an + // embedded newline (a command separator, not whitespace). + private static bool RequiresShellForDenoRunScriptArguments(string? runScriptArguments) => + runScriptArguments is not null && !runScriptArguments.All(IsShellInertRunScriptArgumentCharacter); + + // Characters whose meaning to `sh` is identical to their meaning to TokenizeDenoRunScriptArguments. + // Quoting characters are inert because the tokenizer implements the same POSIX quoting rules the shell + // does. '!' is inert because history expansion is interactive-only and never applies under `sh -c`. + private static bool IsShellInertRunScriptArgumentCharacter(char c) => + c is >= 'a' and <= 'z' + or >= 'A' and <= 'Z' + or >= '0' and <= '9' + or ' ' + or '\t' + or '\'' + or '"' + or '\\' + or '-' + or '_' + or '.' + or '/' + or ':' + or '=' + or '+' + or ',' + or '@' + or '%' + or '^' + or '!'; + + /// + /// Splits a free-form run-script argument string into individual argv entries. + /// + /// + /// PublishAsPackageScript(runScriptArguments: ...) takes a single string because it mirrors what a + /// developer would type in a shell. An exec-form ENTRYPOINT needs a real argument vector, so the string is + /// tokenized here using POSIX-shell word-splitting rules: + /// + /// --port 8080 -> ["--port", "8080"] + /// --name 'my app' -> ["--name", "my app"] + /// --path "/a b" -> ["--path", "/a b"] + /// --msg "say \"hi\"" -> ["--msg", "say \"hi\""] + /// + /// Inputs that need actual shell behavior never reach this method; see + /// . + /// + private static List TokenizeDenoRunScriptArguments(string? runScriptArguments) + { + var tokens = new List(); + if (string.IsNullOrWhiteSpace(runScriptArguments)) + { + return tokens; + } + + var current = new StringBuilder(); + var hasToken = false; + var quote = '\0'; + + for (var index = 0; index < runScriptArguments.Length; index++) + { + var c = runScriptArguments[index]; + + if (quote == '\0' && char.IsWhiteSpace(c)) + { + if (hasToken) + { + tokens.Add(current.ToString()); + current.Clear(); + hasToken = false; + } + + continue; + } + + if (quote == '\0' && c is '\'' or '"') + { + quote = c; + // An empty quoted argument ("" or '') is still an argument. + hasToken = true; + continue; + } + + if (quote != '\0' && c == quote) + { + quote = '\0'; + continue; + } + + // Backslash escapes only apply inside double quotes and outside quotes, matching POSIX shells. + // Inside single quotes every character is literal. + if (c == '\\' && quote != '\'' && index + 1 < runScriptArguments.Length) + { + var next = runScriptArguments[index + 1]; + + // POSIX rules differ by context. Unquoted, a backslash escapes the character that follows it. + // Inside double quotes it is only an escape before $ ` " \ and ; before anything else + // the backslash is retained literally, so `--pattern "\d+"` must stay `--pattern \d+` rather + // than collapsing to `d+`. + // https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_02_03 + if (quote == '\0' || next is '$' or '`' or '"' or '\\' or '\n') + { + index++; + + // A backslash immediately before a newline is a line continuation in both contexts: + // both characters are removed rather than producing a literal newline. + if (next != '\n') + { + current.Append(next); + hasToken = true; + } + + continue; + } + } + + current.Append(c); + hasToken = true; + } + + if (quote != '\0') + { + // A shell rejects this outright: + // $ sh -c "printf '%s' --name 'unterminated" + // sh: unexpected EOF while looking for matching `'' + // Silently closing the quote here would publish an exec-form command that differs from what the + // caller wrote, so fail at build time rather than shipping a container that runs something else. + var quoteKind = quote == '\'' ? "single" : "double"; + throw new InvalidOperationException( + $"The Deno run script arguments '{runScriptArguments}' contain an unterminated {quoteKind} quote. Close the quote so the arguments can be parsed the way a shell would parse them."); + } + + if (hasToken) + { + tokens.Add(current.ToString()); + } + + return tokens; + } + + private static bool ShouldUseFrozenLock(DenoCommandLineAnnotation deno, string workingDirectory) + { + if (deno.NoLock || deno.RuntimeArgs.Contains("--no-lock", StringComparer.Ordinal)) + { + return false; + } + + var lockFile = string.IsNullOrEmpty(deno.Lock) ? "deno.lock" : deno.Lock; + return File.Exists(Path.Combine(workingDirectory, lockFile)); + } + + private static DenoServeEndpointArguments? GetDenoServeEndpointArguments(IResource resource, bool isPublishMode, bool useLiteralTargetPort = false) + { + if (resource is not IResourceWithEndpoints endpointsResource) + { + return null; + } + + var endpoint = endpointsResource.GetEndpoint("http"); + if (!endpoint.Exists) + { + return null; + } + + var host = isPublishMode ? "0.0.0.0" : endpoint.EndpointAnnotation.TargetHost; + object port = useLiteralTargetPort + ? (endpoint.EndpointAnnotation.TargetPort ?? DenoServeDefaultPort).ToString(CultureInfo.InvariantCulture) + : endpoint.Property(EndpointProperty.TargetPort); + + return new(host, port); + } + + private sealed record DenoServeEndpointArguments(string Host, object Port); +} diff --git a/src/Aspire.Hosting.JavaScript/DenoOtlpExporterAnnotation.cs b/src/Aspire.Hosting.JavaScript/DenoOtlpExporterAnnotation.cs new file mode 100644 index 00000000000..1372acf7605 --- /dev/null +++ b/src/Aspire.Hosting.JavaScript/DenoOtlpExporterAnnotation.cs @@ -0,0 +1,34 @@ +// 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; + +internal sealed class DenoOtlpExporterAnnotation : OtlpExporterAnnotation, IReadOnlyDictionary +{ + // Publish targets consume these settings when they provide an OTLP endpoint. Carrying them on + // the exporter annotation preserves Deno's activation intent across late publish transformations + // without requiring environment callbacks to run in a particular order. + private static readonly IReadOnlyDictionary s_activationEnvironmentVariables = + new Dictionary + { + ["OTEL_DENO"] = "true", + }; + + public string this[string key] => s_activationEnvironmentVariables[key]; + + public IEnumerable Keys => s_activationEnvironmentVariables.Keys; + + public IEnumerable Values => s_activationEnvironmentVariables.Values; + + public int Count => s_activationEnvironmentVariables.Count; + + public bool ContainsKey(string key) => s_activationEnvironmentVariables.ContainsKey(key); + + public IEnumerator> GetEnumerator() => s_activationEnvironmentVariables.GetEnumerator(); + + public bool TryGetValue(string key, out string value) => s_activationEnvironmentVariables.TryGetValue(key, out value!); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index 7d59266df20..f788b8d90d0 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -1,6 +1,8 @@ // 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 @@ -44,8 +46,15 @@ public static partial class JavaScriptHostingExtensions 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. @@ -183,16 +192,21 @@ public static IResourceBuilder AddNodeApp(this IDistributedAppl { var defaultBaseImage = new Lazy(() => GetDefaultBaseImage(appDirectory, "alpine", dockerfileContext.Services)); - // Get custom base image from annotation, if present + // 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 ?? defaultBaseImage.Value; + var baseBuildImage = baseImageAnnotation?.BuildImage + ?? packageManager?.DefaultBuildImage + ?? defaultBaseImage.Value; var builderStage = dockerfileContext.Builder .From(baseBuildImage, "build") .EmptyLine() .WorkDir("/app"); - if (resource.TryGetLastAnnotation(out var packageManager)) + 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) @@ -202,20 +216,7 @@ public static IResourceBuilder AddNodeApp(this IDistributedAppl var copiedAllSource = false; if (resource.TryGetLastAnnotation(out var installCommand)) { - // Copy package files first for better layer caching - if (packageManager.PackageFilesPatterns.Count > 0) - { - foreach (var packageFilePattern in packageManager.PackageFilesPatterns) - { - builderStage.Copy(packageFilePattern.Source, packageFilePattern.Destination); - } - } - else - { - builderStage.Copy(".", "."); - copiedAllSource = true; - } - + copiedAllSource = builderStage.CopyPackageFilesForInstall(packageManager); builderStage.AddInstallCommand(packageManager, installCommand); } @@ -227,16 +228,8 @@ public static IResourceBuilder AddNodeApp(this IDistributedAppl if (resource.TryGetLastAnnotation(out var buildCommand)) { - var commandArgs = new List() { packageManager.ExecutableName }; - if (!string.IsNullOrEmpty(packageManager.ScriptCommand)) - { - commandArgs.Add(packageManager.ScriptCommand); - } - commandArgs.Add(buildCommand.ScriptName); - commandArgs.AddRange(buildCommand.Args); - builderStage.EmptyLine() - .Run(string.Join(' ', commandArgs)); + .Run(BuildPackageScriptCommand(packageManager, buildCommand)); } } else @@ -414,6 +407,7 @@ private static IResourceBuilder WithRequiredCommandsFromPackageManage "node" => NodeHelpLink, "npm" => NpmHelpLink, "bun" => BunHelpLink, + "deno" => DenoHelpLink, "yarn" => YarnHelpLink, "pnpm" => PnpmHelpLink, // Unknown/custom package manager: no specific install help link. @@ -557,20 +551,7 @@ public static IResourceBuilder AddBunApp(this IDistributedApplic var copiedAllSource = false; if (resource.TryGetLastAnnotation(out var installCommand)) { - // Copy package files first for better layer caching - if (packageManager.PackageFilesPatterns.Count > 0) - { - foreach (var packageFilePattern in packageManager.PackageFilesPatterns) - { - builderStage.Copy(packageFilePattern.Source, packageFilePattern.Destination); - } - } - else - { - builderStage.Copy(".", "."); - copiedAllSource = true; - } - + copiedAllSource = builderStage.CopyPackageFilesForInstall(packageManager); builderStage.AddInstallCommand(packageManager, installCommand); } @@ -581,16 +562,8 @@ public static IResourceBuilder AddBunApp(this IDistributedApplic if (resource.TryGetLastAnnotation(out var buildCommand)) { - var commandArgs = new List() { packageManager.ExecutableName }; - if (!string.IsNullOrEmpty(packageManager.ScriptCommand)) - { - commandArgs.Add(packageManager.ScriptCommand); - } - commandArgs.Add(buildCommand.ScriptName); - commandArgs.AddRange(buildCommand.Args); - builderStage.EmptyLine() - .Run(string.Join(' ', commandArgs)); + .Run(BuildPackageScriptCommand(packageManager, buildCommand)); } } else @@ -638,7 +611,7 @@ public static IResourceBuilder AddBunApp(this IDistributedApplic prodDepsStage.Copy("package.json", "./"); } - var prodInstallCmd = $"{pm.ExecutableName} {string.Join(' ', install.Args)} {install.ProductionInstallArgs}"; + var prodInstallCmd = BuildProductionInstallCommand(pm, install); if (!string.IsNullOrEmpty(pm.CacheMount)) { prodDepsStage.Run($"--mount=type=cache,target={pm.CacheMount} {prodInstallCmd}"); @@ -776,6 +749,408 @@ private static IResourceBuilder WithBunDefaults(this IReso 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. @@ -811,6 +1186,10 @@ public static IResourceBuilder AddJavaScriptApp(this IDis /// 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. @@ -845,6 +1224,10 @@ public static IResourceBuilder PublishAsStaticWebsite( /// /// 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 @@ -970,7 +1353,7 @@ private static IResourceBuilder PublishAsStaticWebsiteCore builder.WithAnnotation(annotation) .ClearContainerFilesSources() .WithContainerFilesSource(GetContainerFilesSourcePath(options.OutputPath)) - .WithOtlpExporter(); + .WithOtlpExporterIfMissing(); if (builder.Resource.TryGetLastAnnotation(out var dockerfileBuildAnnotation)) { @@ -993,6 +1376,10 @@ private static IResourceBuilder PublishAsStaticWebsiteCore /// 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 @@ -1028,7 +1415,7 @@ public static IResourceBuilder PublishAsNodeServer(this IR builder.WithAnnotation(annotation) .ClearContainerFilesSources() .WithContainerFilesSource(GetContainerFilesSourcePath(outputPath)) - .WithOtlpExporter() + .WithOtlpExporterIfMissing() .WithEnvironment("HOST", "0.0.0.0") .WithEnvironment("HOSTNAME", "0.0.0.0"); @@ -1041,14 +1428,16 @@ public static IResourceBuilder PublishAsNodeServer(this IR } /// - /// Configures the JavaScript application to publish as a Node.js server that uses a package.json script at runtime. + /// 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 package.json script to run in the published container. + /// 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, or bun run start. + /// 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, @@ -1057,8 +1446,9 @@ public static IResourceBuilder PublishAsNodeServer(this IR /// The updated resource builder. /// /// - /// Use this method for frameworks where the production server depends on packages in node_modules at runtime. - /// The resulting container includes the full application with production dependencies installed. + /// 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 @@ -1066,6 +1456,20 @@ public static IResourceBuilder PublishAsNodeServer(this IR /// (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. /// @@ -1091,7 +1495,7 @@ public static IResourceBuilder PublishAsPackageScript(this builder.WithAnnotation(annotation) .ClearContainerFilesSources() - .WithOtlpExporter() + .WithOtlpExporterIfMissing() .WithEnvironment("HOST", "0.0.0.0") .WithEnvironment("HOSTNAME", "0.0.0.0"); @@ -1103,10 +1507,46 @@ public static IResourceBuilder PublishAsPackageScript(this 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 = $"{packageManager.ExecutableName} {string.Join(' ', installCommand.Args)}"; + var installCmd = JoinDockerShellCommand([packageManager.ExecutableName, .. installCommand.Args]); if (!string.IsNullOrEmpty(packageManager.CacheMount)) { builderStage.Run($"--mount=type=cache,target={packageManager.CacheMount} {installCmd}"); @@ -1117,6 +1557,82 @@ private static void AddInstallCommand(this DockerfileStage builderStage, JavaScr } } + /// + /// 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, @@ -1178,9 +1694,13 @@ private static IResourceBuilder CreateDefaultJavaScriptAppBuilder(out var packageManager)) { - // Get custom base image from annotation, if present + // 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 ?? GetDefaultBaseImage(appDirectory, "slim", dockerfileContext.Services); + var baseImage = baseImageAnnotation?.BuildImage + ?? packageManager.DefaultBuildImage + ?? GetDefaultBaseImage(appDirectory, "slim", dockerfileContext.Services); var dockerBuilder = publishMode is not null ? dockerfileContext.Builder.From(baseImage, "build").WorkDir("/app") @@ -1190,21 +1710,7 @@ private static IResourceBuilder CreateDefaultJavaScriptAppBuilder 0) - { - foreach (var packageFilePattern in packageManager.PackageFilesPatterns) - { - dockerBuilder.Copy(packageFilePattern.Source, packageFilePattern.Destination); - } - } - else - { - dockerBuilder.Copy(".", "."); - copiedAllSource = true; - } + var copiedAllSource = dockerBuilder.CopyPackageFilesForInstall(packageManager); if (c.Resource.TryGetLastAnnotation(out var installCommand)) { @@ -1219,15 +1725,7 @@ private static IResourceBuilder CreateDefaultJavaScriptAppBuilder(out var buildCommand)) { - var commandArgs = new List() { packageManager.ExecutableName }; - if (!string.IsNullOrEmpty(packageManager.ScriptCommand)) - { - commandArgs.Add(packageManager.ScriptCommand); - } - commandArgs.Add(buildCommand.ScriptName); - commandArgs.AddRange(buildCommand.Args); - - dockerBuilder.Run(string.Join(' ', commandArgs)); + dockerBuilder.Run(BuildPackageScriptCommand(packageManager, buildCommand)); } switch (publishMode?.Mode) @@ -1260,6 +1758,57 @@ private static IResourceBuilder CreateDefaultJavaScriptAppBuilder CreateDefaultJavaScriptAppBuilder CreateDefaultJavaScriptAppBuilder WithBun(this IResourceBuild // bun supports passing script flags without the `--` separator. CommandSeparator = null, ResolvePackageScriptRuntimeImage = buildImage => buildImage, + DefaultBuildImage = DefaultBunImage, }) .WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs]) { @@ -1890,6 +2436,109 @@ 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.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"] @@ -2164,25 +2813,50 @@ internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder var workingDirectory = Path.GetFullPath(resource.WorkingDirectory); return builder.WithDebugSupport( - mode => + 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 isPackageManagerScript = hasRunScript && hasPackageManager; - - return new JavaScriptLaunchConfiguration(launchConfigType) + 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 = mode, - RuntimeExecutable = isPackageManagerScript ? pmAnnotation!.ExecutableName : launchConfigType, + 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 @@ -2198,6 +2872,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 => { @@ -2337,18 +3017,18 @@ private static void AddInstaller(IResourceBuilder resource if (existingResource is not null) { - // Installer already exists, update its configuration based on install parameter - if (!install) + // 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 { - // Remove wait annotation if install is false - resource.Resource.Annotations.OfType() - .Where(w => w.Resource == existingResource.Resource) - .ToList() - .ForEach(w => resource.Resource.Annotations.Remove(w)); - - // Add WithExplicitStart to the existing installer - existingResource.WithExplicitStart(); + DisableInstaller(resource, existingResource); } + return; } @@ -2393,6 +3073,46 @@ private static void AddInstaller(IResourceBuilder resource } } + 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; diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptPackageManagerAnnotation.cs b/src/Aspire.Hosting.JavaScript/JavaScriptPackageManagerAnnotation.cs index c2e3ce6f1c3..11e6f56500c 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptPackageManagerAnnotation.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptPackageManagerAnnotation.cs @@ -57,4 +57,15 @@ public sealed class JavaScriptPackageManagerAnnotation(string executableName, st /// Gets or sets a callback to resolve the default PublishAsPackageScript runtime image from the build image. /// internal Func? ResolvePackageScriptRuntimeImage { get; init; } + + /// + /// Gets the build image to use when the resource has no explicit build image configured. + /// + /// + /// Package managers such as bun and deno are not present in the default Node.js base images, so Dockerfile + /// generation must fall back to a runtime-specific image rather than the Node.js default. This is consulted + /// after so that a caller-supplied build image always + /// wins, and it covers the case where the caller configured only a runtime image. + /// + internal string? DefaultBuildImage { get; init; } } diff --git a/src/Aspire.Hosting.JavaScript/README.md b/src/Aspire.Hosting.JavaScript/README.md index a4f8577677c..9a3ca4d32ba 100644 --- a/src/Aspire.Hosting.JavaScript/README.md +++ b/src/Aspire.Hosting.JavaScript/README.md @@ -21,7 +21,7 @@ In the AppHost, add a JavaScript app resource with either C# or TypeScript: ```csharp var builder = DistributedApplication.CreateBuilder(args); -builder.AddJavaScriptApp("frontend", "../frontend", "app.js"); +builder.AddJavaScriptApp("frontend", "../frontend", "dev"); builder.Build().Run(); ``` @@ -33,17 +33,34 @@ import { createBuilder } from "./.aspire/modules/aspire.mjs"; const builder = await createBuilder(); -await builder.addJavaScriptApp("frontend", "../frontend", "app.js"); +await builder.addJavaScriptApp("frontend", "../frontend", { runScriptName: "dev" }); await builder.build().run(); ``` +### Runtime and framework-specific apps + +Use the most specific helper for the runtime or framework hosting the app: + +| Runtime or framework | C# / TypeScript APIs | Use | +|---|---|---| +| Node.js | `AddNodeApp` / `addNodeApp` | Run a JavaScript or TypeScript entry point with Node.js. | +| Bun | `AddBunApp` / `addBunApp` | Run an entry point with Bun. | +| Deno | `AddDenoApp` / `addDenoApp` | Run an entry point with Deno. | +| Vite | `AddViteApp` / `addViteApp` | Run a Vite development server and publish the app as a container. | +| Next.js | `AddNextJsApp` / `addNextJsApp` | Run and publish a Next.js application. | + +The runtime used for local development must be installed and available on `PATH`. Deno apps run their +entry point directly by default; use `WithDenoTask`, `WithDenoServe`, and the other `WithDeno*` APIs +to select another mode or configure permissions and runtime flags. + ## Additional documentation https://aspire.dev/integrations/gallery/ https://aspire.dev/integrations/frameworks/javascript/ https://github.com/microsoft/aspire-samples/tree/main/samples/aspire-with-javascript https://github.com/microsoft/aspire-samples/tree/main/samples/aspire-with-node +https://docs.deno.com/runtime/ ## Feedback & contributing diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesAspireDashboardResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesAspireDashboardResource.cs index 3a326f614e6..29d1635f32e 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesAspireDashboardResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesAspireDashboardResource.cs @@ -22,4 +22,6 @@ public class KubernetesAspireDashboardResource(string name) : ContainerResource( /// Gets the OTLP gRPC endpoint for receiving telemetry data. /// public EndpointReference OtlpGrpcEndpoint => new(this, "otlp-grpc"); + + internal EndpointReference OtlpHttpEndpoint => new(this, "otlp-http"); } diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs index d4b40c93d05..1373c4bed9b 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs @@ -493,9 +493,9 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) } // Configure OTLP for resources if dashboard is enabled - if (DashboardEnabled && Dashboard?.Resource.OtlpGrpcEndpoint is EndpointReference otlpGrpcEndpoint) + if (DashboardEnabled && Dashboard?.Resource is KubernetesAspireDashboardResource dashboardResource) { - ConfigureOtlp(r, otlpGrpcEndpoint); + ConfigureOtlp(r, dashboardResource); } // Create a Kubernetes compute resource for the resource @@ -542,20 +542,49 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) return null; } - private static void ConfigureOtlp(IResource resource, EndpointReference otlpEndpoint) + private static void ConfigureOtlp(IResource resource, KubernetesAspireDashboardResource dashboard) { - if (resource is IResourceWithEnvironment resourceWithEnv && resource.Annotations.OfType().Any()) + var otlpExporter = GetEffectiveOtlpExporterAnnotation(resource); + + if (resource is IResourceWithEnvironment resourceWithEnv && + otlpExporter is not null) { + var (otlpEndpoint, protocol) = otlpExporter.RequiredProtocol switch + { + OtlpProtocol.HttpProtobuf => (dashboard.OtlpHttpEndpoint, "http/protobuf"), + OtlpProtocol.HttpJson => (dashboard.OtlpHttpEndpoint, "http/json"), + _ => (dashboard.OtlpGrpcEndpoint, "grpc"), + }; + resourceWithEnv.Annotations.Add(new EnvironmentCallbackAnnotation(context => { context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpEndpoint] = otlpEndpoint; - context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpProtocol] = "grpc"; + context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpProtocol] = protocol; context.EnvironmentVariables[KnownOtelConfigNames.ServiceName] = resource.Name; + ApplyActivationEnvironmentVariables(resource, context.EnvironmentVariables); return Task.CompletedTask; })); } } + private static void ApplyActivationEnvironmentVariables( + IResource resource, + IDictionary environmentVariables) + { + if (GetEffectiveOtlpExporterAnnotation(resource) is not IReadOnlyDictionary activationEnvironmentVariables) + { + return; + } + + foreach (var (name, value) in activationEnvironmentVariables) + { + environmentVariables.TryAdd(name, value); + } + } + + private static OtlpExporterAnnotation? GetEffectiveOtlpExporterAnnotation(IResource resource) + => resource.Annotations.OfType().LastOrDefault(); + /// /// Resolves a at deploy time. Used by pipeline steps /// (e.g., TLS bootstrap, gateway address discovery) where parameter values are expected diff --git a/src/Aspire.Hosting/OtlpConfigurationExtensions.cs b/src/Aspire.Hosting/OtlpConfigurationExtensions.cs index 796a49deb9a..91224058581 100644 --- a/src/Aspire.Hosting/OtlpConfigurationExtensions.cs +++ b/src/Aspire.Hosting/OtlpConfigurationExtensions.cs @@ -51,7 +51,39 @@ public static void AddOtlpEnvironment(IResource resource, IConfiguration configu RegisterOtlpEnvironment(resource, configuration, environment); } - private static void RegisterOtlpEnvironment(IResource resource, IConfiguration configuration, IHostEnvironment environment) + /// + /// Configures OTLP export only when the required collector endpoint is available. + /// + /// The resource type. + /// The resource builder. + /// The required OTLP protocol. + /// The . + /// + /// The exporter annotation is retained when no endpoint is available, but OTLP environment variables are + /// omitted so workloads that can operate without telemetry remain runnable. + /// + [AspireExportIgnore(Reason = "Optional OTLP exporter registration is currently used by C# hosting integrations only.")] + public static IResourceBuilder WithOtlpExporterIfEndpointAvailable( + this IResourceBuilder builder, + OtlpProtocol protocol) where T : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Resource.Annotations.Add(new OtlpExporterAnnotation { RequiredProtocol = protocol }); + RegisterOtlpEnvironment( + builder.Resource, + builder.ApplicationBuilder.Configuration, + builder.ApplicationBuilder.Environment, + skipIfEndpointUnavailable: true); + + return builder; + } + + private static void RegisterOtlpEnvironment( + IResource resource, + IConfiguration configuration, + IHostEnvironment environment, + bool skipIfEndpointUnavailable = false) { // Configure OpenTelemetry in projects using environment variables. // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/configuration/sdk-environment-variables.md @@ -64,7 +96,8 @@ private static void RegisterOtlpEnvironment(IResource resource, IConfiguration c return; } - if (!resource.TryGetLastAnnotation(out var otlpExporterAnnotation)) + var otlpExporterAnnotation = GetEffectiveOtlpExporterAnnotation(resource); + if (otlpExporterAnnotation is null) { return; } @@ -80,6 +113,12 @@ private static void RegisterOtlpEnvironment(IResource resource, IConfiguration c } else { + if (skipIfEndpointUnavailable && + !HasConfiguredOtlpEndpoint(configuration, otlpExporterAnnotation.RequiredProtocol)) + { + return; + } + // Fall back to resolving from configuration. This is the case when the dashboard resource // is not in the model (e.g. in tests or publish mode). var (url, protocol) = OtlpEndpointResolver.ResolveOtlpEndpoint(configuration, otlpExporterAnnotation.RequiredProtocol); @@ -87,6 +126,11 @@ private static void RegisterOtlpEnvironment(IResource resource, IConfiguration c context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpProtocol] = protocol; } + // Runtime-specific exporters are activated in the same step that confirms an endpoint exists. + // This keeps activation independent of environment callback ordering and avoids enabling exporters + // for dashboard-free workloads. + ApplyActivationEnvironmentVariables(resource, context.EnvironmentVariables); + // Set the service name and instance id to the resource name and UID. Values are injected by DCP. context.EnvironmentVariables[KnownOtelConfigNames.ResourceAttributes] = "service.instance.id={{- index .Annotations \"" + CustomResource.OtelServiceInstanceIdAnnotation + "\" -}}"; context.EnvironmentVariables[KnownOtelConfigNames.ServiceName] = "{{- index .Annotations \"" + CustomResource.OtelServiceNameAnnotation + "\" -}}"; @@ -121,6 +165,41 @@ private static void RegisterOtlpEnvironment(IResource resource, IConfiguration c })); } + private static bool HasConfiguredOtlpEndpoint(IConfiguration configuration, OtlpProtocol? requiredProtocol) + { + var grpcEndpoint = configuration.GetString( + KnownConfigNames.DashboardOtlpGrpcEndpointUrl, + KnownConfigNames.Legacy.DashboardOtlpGrpcEndpointUrl); + var httpEndpoint = configuration.GetString( + KnownConfigNames.DashboardOtlpHttpEndpointUrl, + KnownConfigNames.Legacy.DashboardOtlpHttpEndpointUrl); + + return requiredProtocol switch + { + OtlpProtocol.Grpc => !string.IsNullOrWhiteSpace(grpcEndpoint), + OtlpProtocol.HttpProtobuf or OtlpProtocol.HttpJson => !string.IsNullOrWhiteSpace(httpEndpoint), + _ => !string.IsNullOrWhiteSpace(grpcEndpoint) || !string.IsNullOrWhiteSpace(httpEndpoint), + }; + } + + private static void ApplyActivationEnvironmentVariables( + IResource resource, + IDictionary environmentVariables) + { + if (GetEffectiveOtlpExporterAnnotation(resource) is not IReadOnlyDictionary activationEnvironmentVariables) + { + return; + } + + foreach (var (name, value) in activationEnvironmentVariables) + { + environmentVariables.TryAdd(name, value); + } + } + + private static OtlpExporterAnnotation? GetEffectiveOtlpExporterAnnotation(IResource resource) + => resource.Annotations.OfType().LastOrDefault(); + /// /// Injects the appropriate environment variables to allow the resource to enable sending telemetry to the dashboard. /// diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs index 74758ee6292..29dd7d2b36c 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs @@ -1,9 +1,11 @@ // 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 // Type is for evaluation purposes only #pragma warning disable ASPIREBROWSERLOGS001 // Type is for evaluation purposes only #pragma warning disable ASPIRECOMPUTE002 +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.RegularExpressions; using Aspire.Hosting.Azure; @@ -2411,6 +2413,38 @@ public void Scanner_PackageManagerMethods_ExpandToAllJavaScriptResourceTypes(str Assert.Contains(expandedTypeIds, id => id.Contains(nameof(JavaScript.ViteAppResource), StringComparison.Ordinal)); } + [Fact] + public void DenoPublicApis_AreExperimental() + { + var denoMethods = typeof(JavaScriptHostingExtensions) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(method => method.Name == nameof(JavaScriptHostingExtensions.AddDenoApp) || + method.Name.StartsWith("WithDeno", StringComparison.Ordinal)) + .ToList(); + + Assert.NotEmpty(denoMethods); + Assert.Contains(denoMethods, method => method.Name == nameof(JavaScriptHostingExtensions.AddDenoApp)); + Assert.Contains(denoMethods, method => method.Name.StartsWith("WithDeno", StringComparison.Ordinal)); + + Assert.All(denoMethods, method => + { + var experimental = Assert.Single(method.GetCustomAttributes()); + Assert.Equal("ASPIREDENO001", experimental.DiagnosticId); + }); + + foreach (var type in new[] + { + typeof(Aspire.Hosting.JavaScript.DenoAppResource), + typeof(Aspire.Hosting.JavaScript.DenoInspectMode), + typeof(Aspire.Hosting.JavaScript.DenoNodeModulesDirMode), + typeof(Aspire.Hosting.JavaScript.DenoPermissionKind), + }) + { + var experimental = Assert.Single(type.GetCustomAttributes()); + Assert.Equal("ASPIREDENO001", experimental.DiagnosticId); + } + } + private const string ApiExportPackageName = "Aspire.Hosting.CodeGeneration.TypeScript.Tests"; private const string ApiExportPackageVersion = "13.5.0"; diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index b54c549b4a5..db5eeb8f5f6 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs @@ -466,6 +466,38 @@ public async Task PublishAsync_WithDashboardEnabled_IncludesDashboardService() await Verify(composeContent, "yaml"); } + [Fact] + public async Task PublishAsync_WithDashboard_HonorsRequiredOtlpProtocols() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + builder.Services.AddSingleton(); + + builder.AddDockerComposeEnvironment("docker-compose") + .WithDashboard(); + + builder.AddContainer("default", "my-default") + .WithOtlpExporter(); + + builder.AddContainer("grpc", "my-grpc") + .WithOtlpExporter(OtlpProtocol.Grpc); + + builder.AddContainer("http-protobuf", "my-http-protobuf") + .WithOtlpExporter(OtlpProtocol.HttpProtobuf); + + builder.AddContainer("http-json", "my-http-json") + .WithOtlpExporter(OtlpProtocol.HttpJson); + + var app = builder.Build(); + app.Run(); + + var composePath = Path.Combine(workspace.Path, "docker-compose.yaml"); + Assert.True(File.Exists(composePath)); + + await Verify(File.ReadAllText(composePath), "yaml"); + } + [Fact] public async Task PublishAsync_WithDashboardDisabled_DoesNotIncludeDashboardService() { @@ -492,6 +524,65 @@ public async Task PublishAsync_WithDashboardDisabled_DoesNotIncludeDashboardServ await Verify(composeContent, "yaml"); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task PublishAsync_DenoNativeOpenTelemetryFollowsInjectedEndpoint(bool dashboardEnabled) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + builder.Services.AddSingleton(); + + builder.AddDockerComposeEnvironment("docker-compose") + .WithDashboard(dashboardEnabled); + + builder.AddContainer("deno", "denoland/deno") + .WithOtlpExporterIfEndpointAvailable(OtlpProtocol.HttpProtobuf) + .WithOtlpExporterActivationEnvironmentVariable("OTEL_DENO", "true"); + + using var app = builder.Build(); + await app.RunAsync(); + + var composePath = Path.Combine(workspace.Path, "docker-compose.yaml"); + Assert.True(File.Exists(composePath)); + var compose = await File.ReadAllTextAsync(composePath); + + var hasEndpoint = compose.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", StringComparison.Ordinal); + var hasNativeDenoTelemetry = compose.Contains("OTEL_DENO", StringComparison.Ordinal); + + Assert.Equal(dashboardEnabled, hasEndpoint); + Assert.Equal(hasEndpoint, hasNativeDenoTelemetry); + } + + [Fact] + public async Task PublishAsync_LastOtlpExporterAnnotationDoesNotApplySupersededActivation() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + builder.Services.AddSingleton(); + + builder.AddDockerComposeEnvironment("docker-compose") + .WithDashboard(); + + builder.AddContainer("deno", "denoland/deno") + .WithOtlpExporterIfEndpointAvailable(OtlpProtocol.HttpProtobuf) + .WithOtlpExporterActivationEnvironmentVariable("OTEL_DENO", "true") + .WithOtlpExporter(); + + using var app = builder.Build(); + await app.RunAsync(); + + var composePath = Path.Combine(workspace.Path, "docker-compose.yaml"); + Assert.True(File.Exists(composePath)); + var compose = await File.ReadAllTextAsync(composePath); + + Assert.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", compose); + Assert.Contains("OTEL_EXPORTER_OTLP_PROTOCOL: \"grpc\"", compose); + Assert.False(compose.Contains("OTEL_DENO", StringComparison.Ordinal)); + } + [Fact] public async Task PublishAsync_WithDashboard_UsesCustomConfiguration() { diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_GeneratesValidDockerComposeFile.verified.yaml b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_GeneratesValidDockerComposeFile.verified.yaml index 71e3262b494..a58a0b410cd 100644 --- a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_GeneratesValidDockerComposeFile.verified.yaml +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_GeneratesValidDockerComposeFile.verified.yaml @@ -1,4 +1,4 @@ -services: +services: docker-compose-dashboard: image: "mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest" ports: diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_WithDashboard_HonorsRequiredOtlpProtocols.verified.yaml b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_WithDashboard_HonorsRequiredOtlpProtocols.verified.yaml new file mode 100644 index 00000000000..cf641764175 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_WithDashboard_HonorsRequiredOtlpProtocols.verified.yaml @@ -0,0 +1,46 @@ +services: + docker-compose-dashboard: + image: "mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest" + ports: + - "18888" + expose: + - "18889" + - "18890" + networks: + - "aspire" + restart: "always" + default: + image: "my-default:latest" + environment: + OTEL_EXPORTER_OTLP_ENDPOINT: "http://docker-compose-dashboard:18889" + OTEL_EXPORTER_OTLP_PROTOCOL: "grpc" + OTEL_SERVICE_NAME: "default" + networks: + - "aspire" + grpc: + image: "my-grpc:latest" + environment: + OTEL_EXPORTER_OTLP_ENDPOINT: "http://docker-compose-dashboard:18889" + OTEL_EXPORTER_OTLP_PROTOCOL: "grpc" + OTEL_SERVICE_NAME: "grpc" + networks: + - "aspire" + http-protobuf: + image: "my-http-protobuf:latest" + environment: + OTEL_EXPORTER_OTLP_ENDPOINT: "http://docker-compose-dashboard:18890" + OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf" + OTEL_SERVICE_NAME: "http-protobuf" + networks: + - "aspire" + http-json: + image: "my-http-json:latest" + environment: + OTEL_EXPORTER_OTLP_ENDPOINT: "http://docker-compose-dashboard:18890" + OTEL_EXPORTER_OTLP_PROTOCOL: "http/json" + OTEL_SERVICE_NAME: "http-json" + networks: + - "aspire" +networks: + aspire: + driver: "bridge" diff --git a/tests/Aspire.Hosting.JavaScript.Tests/AddDenoAppTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/AddDenoAppTests.cs new file mode 100644 index 00000000000..108e1b35236 --- /dev/null +++ b/tests/Aspire.Hosting.JavaScript.Tests/AddDenoAppTests.cs @@ -0,0 +1,2779 @@ +// 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 +#pragma warning disable ASPIREDENO001 // Type is for evaluation purposes only +#pragma warning disable ASPIREJAVASCRIPT001 // Type is for evaluation purposes only + +using System.Diagnostics; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using Aspire.TestUtilities; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.JavaScript.Tests; + +public class AddDenoAppTests(ITestOutputHelper outputHelper) +{ + [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(false)] + [InlineData(true)] + public async Task ManifestDoesNotActivateDenoWithoutATargetOtlpEndpoint(bool configurePublish) + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish).WithResourceCleanUp(true); + var denoApp = builder.AddDenoApp("denoapp", AppContext.BaseDirectory, "main.ts"); + + if (configurePublish) + { + denoApp.WithDeno() + .PublishAsPackageScript(); + } + + var exporter = Assert.Single(denoApp.Resource.Annotations.OfType()); + Assert.Equal(OtlpProtocol.HttpProtobuf, exporter.RequiredProtocol); + Assert.IsAssignableFrom>(exporter); + + var manifest = await ManifestUtils.GetManifest(denoApp.Resource); + + Assert.Null(manifest["env"]?["OTEL_DENO"]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task VerifyDockerfile(bool includePackageJson) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.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, workspace.Path); + + var dockerfilePath = Path.Combine(workspace.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 workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.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, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + await Verify(dockerfileContents); + Assert.Equal("COPY --from=build /app /app", GetDockerfileLine(dockerfileContents, "COPY --from=build /app")); + Assert.Equal("COPY --from=build /deno-dir /deno-dir", GetDockerfileLine(dockerfileContents, "COPY --from=build /deno-dir")); + } + + [Fact] + public async Task VerifyDockerfileEmitsPerDockerfileDockerignore() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.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, workspace.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(workspace.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. + // Deno can materialize node_modules for npm compatibility, so keep local dependency folders + // out of the generated build context like the Bun/Node variants do. + var dockerBuildAnnotation = denoApp.Resource.Annotations.OfType().Single(); + Assert.Equal(ignoreContents, dockerBuildAnnotation.BuildContextIgnoreContent); + } + + [Fact] + public async Task VerifyDockerfile_PreCachesDependenciesAndShipsDenoDir() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts"); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + + // The build stage must pre-cache direct run/serve entrypoints into DENO_DIR. Without a deno.lock, + // plain `deno cache` is used. + Assert.Equal("RUN deno cache main.ts", GetDockerfileLine(dockerfileContents, "RUN deno cache")); + // DENO_DIR must be pinned deterministically in both stages... + Assert.Collection( + dockerfileContents.Split('\n').Select(line => line.TrimEnd('\r')).Where(line => line.StartsWith("ENV DENO_DIR", StringComparison.Ordinal)), + line => Assert.Equal("ENV DENO_DIR=/deno-dir", line), + line => Assert.Equal("ENV DENO_DIR=/deno-dir", line)); + // ...and the populated cache copied into the runtime stage. + Assert.Equal( + "COPY --from=build --chown=deno:deno /deno-dir /deno-dir", + GetDockerfileLine(dockerfileContents, "COPY --from=build --chown=deno:deno /deno-dir")); + // NODE_ENV must be set for Deno's npm-compatibility mode, mirroring the Bun publish block. + Assert.Equal("ENV NODE_ENV=production", GetDockerfileLine(dockerfileContents, "ENV NODE_ENV")); + // Runtime uses only the build-stage cache instead of re-fetching dependencies from the network. + Assert.Equal( + """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--cached-only","main.ts"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Fact] + public async Task VerifyDockerfile_UsesFrozenCacheWhenDenoLockExists() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + // A committed lockfile means the build should fail fast on drift rather than silently re-resolve. + File.WriteAllText(Path.Combine(appDir, "deno.lock"), "{}"); + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts"); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + + Assert.Equal("RUN deno cache --frozen main.ts", GetDockerfileLine(dockerfileContents, "RUN deno cache")); + } + + [Fact] + public async Task VerifyDockerfile_CacheUsesConfiguredResolutionAndLockFlags() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "custom.lock"), "{}"); + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoConfig("deno.json") + .WithDenoImportMap("import_map.json") + .WithDenoLock("custom.lock") + .WithDenoNodeModulesDir(DenoNodeModulesDirMode.Auto); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal( + "RUN deno cache --config deno.json --import-map import_map.json --lock custom.lock --node-modules-dir=auto --frozen main.ts", + GetDockerfileLine(dockerfileContents, "RUN deno cache")); + } + + [Fact] + public async Task VerifyDockerfile_TaskEntrypointSkipsOpaqueTaskCache() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "custom.lock"), "{}"); + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoTask("start") + .WithDenoConfig("deno.json") + .WithDenoImportMap("import_map.json") + .WithDenoLock("custom.lock") + .WithDenoNodeModulesDir(DenoNodeModulesDirMode.Auto) + .WithDenoUnstable("sloppy-imports"); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + await Verify(File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"))); + } + + [Fact] + public async Task VerifyDockerfile_WithRunScriptUsesDenoTaskEntrypoint() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"start":"deno run -A main.ts"}}"""); + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts") + .WithRunScript("start", ["--my-arg"]); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal("RUN mkdir -p /deno-dir", GetDockerfileLine(dockerfileContents, "RUN ")); + Assert.Equal("""ENTRYPOINT ["deno","task","start","--my-arg"]""", GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Fact] + public async Task VerifyDockerfile_WithRunScriptAndDenoFlagsUsesDenoTaskEntrypoint() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"start":"deno run -A main.ts"}}"""); + File.WriteAllText(Path.Combine(appDir, "deno.lock"), "{}"); + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts") + .WithRunScript("start", ["--my-arg"]) + .WithDenoConfig("deno.json") + .WithDenoImportMap("import_map.json") + .WithDenoLock("deno.lock") + .WithDenoNodeModulesDir(DenoNodeModulesDirMode.Auto); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal("RUN mkdir -p /deno-dir", GetDockerfileLine(dockerfileContents, "RUN ")); + Assert.Equal( + """ENTRYPOINT ["deno","task","--config","deno.json","--lock","deno.lock","--node-modules-dir=auto","start","--my-arg"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Fact] + public async Task VerifyDockerfile_PublishAsPackageScriptWithDenoDoesNotRequireProductionInstallArgs() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"build":"deno run -A build.ts","start":"deno run -A main.ts"}}"""); + + var app = builder.AddJavaScriptApp("js", appDir) + .WithDeno() + .WithBuildScript("build") + .PublishAsPackageScript("start", "-- --port $PORT"); + + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + await Verify(dockerfileContents); + Assert.Equal("COPY --from=build --chown=deno:deno /app /app", GetDockerfileLine(dockerfileContents, "COPY --from=build --chown=deno:deno /app")); + // Deno's dependency store must travel to the runtime stage or the container re-downloads on first run. + Assert.Equal("COPY --from=build --chown=deno:deno /deno-dir /deno-dir", GetDockerfileLine(dockerfileContents, "COPY --from=build --chown=deno:deno /deno-dir")); + Assert.Equal("USER deno", GetDockerfileLine(dockerfileContents, "USER")); + Assert.Equal("""ENTRYPOINT ["sh","-c","exec deno task start -- --port $PORT"]""", GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Fact] + public async Task VerifyDockerfile_AddDenoAppPackageScriptRunsInstallAndBuildAndOverridesLocalMode() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText( + Path.Combine(appDir, "deno.json"), + """{"tasks":{"build prod":"deno run -A build.ts","start":"deno run -A main.ts"}}"""); + + var app = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoTask("local") + .WithBuildScript("build prod") + .PublishAsPackageScript("start", "--my-arg"); + + var exporter = Assert.Single(app.Resource.Annotations.OfType()); + Assert.Equal(OtlpProtocol.HttpProtobuf, exporter.RequiredProtocol); + + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + await Verify(dockerfileContents); + Assert.Equal("RUN deno install", GetDockerfileLine(dockerfileContents, "RUN deno install")); + Assert.Equal("RUN deno task 'build prod'", GetDockerfileLine(dockerfileContents, "RUN deno task")); + Assert.Equal( + """ENTRYPOINT ["deno","task","start","--my-arg"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Fact] + public async Task VerifyDockerfile_AddDenoAppPublishAsStaticWebsiteThrows() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var app = builder.AddDenoApp("js", appDir, "main.ts") + .PublishAsStaticWebsite(); + + var exception = await Assert.ThrowsAsync( + () => ManifestUtils.GetManifest(app.Resource, workspace.Path)); + + Assert.Equal( + "Generated Deno Dockerfiles do not support PublishAsStaticWebsite. Use AddJavaScriptApp(...).WithDeno() or provide a custom Dockerfile.", + exception.Message); + } + + [Fact] + public async Task VerifyDockerfile_AddDenoAppPublishAsNodeServerThrows() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var app = builder.AddDenoApp("js", appDir, "main.ts") + .PublishAsNodeServer("server.js"); + + var exception = await Assert.ThrowsAsync( + () => ManifestUtils.GetManifest(app.Resource, workspace.Path)); + + Assert.Equal( + "Generated Deno Dockerfiles do not support PublishAsNodeServer. Use AddJavaScriptApp(...).WithDeno() or provide a custom Dockerfile.", + exception.Message); + } + + [Fact] + public async Task VerifyDockerfile_DenoPackageScriptCopiesReferencedMetadataBeforeInstall() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"importMap":"import_map.json","tasks":{"start":"deno run main.ts"}}"""); + File.WriteAllText(Path.Combine(appDir, "import_map.json"), """{"imports":{}}"""); + + var app = builder.AddDenoApp("js", appDir, "main.ts") + .PublishAsPackageScript("start"); + + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + var dockerfileLines = File.ReadAllLines(Path.Combine(workspace.Path, "js.Dockerfile")); + var copySourceIndex = Array.IndexOf(dockerfileLines, "COPY . ."); + var installIndex = Array.IndexOf(dockerfileLines, "RUN deno install"); + + Assert.True(copySourceIndex >= 0, string.Join(Environment.NewLine, dockerfileLines)); + Assert.True(installIndex > copySourceIndex, string.Join(Environment.NewLine, dockerfileLines)); + } + + [Fact] + public async Task VerifyDockerfile_JavaScriptAppWithDenoCopiesReferencedMetadataBeforeInstall() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"importMap":"import_map.json","tasks":{"start":"deno run main.ts"}}"""); + File.WriteAllText(Path.Combine(appDir, "import_map.json"), """{"imports":{}}"""); + + var app = builder.AddJavaScriptApp("js", appDir) + .WithDeno() + .PublishAsPackageScript("start"); + + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + var dockerfileLines = File.ReadAllLines(Path.Combine(workspace.Path, "js.Dockerfile")); + var copySourceIndex = Array.IndexOf(dockerfileLines, "COPY . ."); + var installIndex = Array.IndexOf(dockerfileLines, "RUN deno install"); + + Assert.True(copySourceIndex >= 0, string.Join(Environment.NewLine, dockerfileLines)); + Assert.True(installIndex > copySourceIndex, string.Join(Environment.NewLine, dockerfileLines)); + } + + [Fact] + public async Task VerifyDockerfile_DefaultPublishPermissionsAreLeastPrivilege() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var app = builder.AddDenoApp("js", appDir, "main.ts"); + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal( + """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--cached-only","main.ts"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Fact] + public async Task VerifyDockerfile_DenyOnlyPermissionsNarrowPublishDefaults() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var app = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoDeny(DenoPermissionKind.Read, @"secrets\private"); + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal( + """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--deny-read=secrets/private","--cached-only","main.ts"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Fact] + public async Task VerifyDockerfile_ExplicitPublishPermissionsArePreservedAndNormalized() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var app = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoAllow(DenoPermissionKind.Read, @"data\read") + .WithDenoDeny(DenoPermissionKind.Write, @"data\write") + .WithDenoAllow(DenoPermissionKind.Ffi, @"native\lib"); + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal( + """ENTRYPOINT ["deno","run","--allow-read=data/read","--deny-write=data/write","--allow-ffi=native/lib","--cached-only","main.ts"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Theory] + [InlineData("--env-file")] + [InlineData("--env-file=")] + [InlineData("--env-file=.env.production")] + [InlineData("--env-file=../.env")] + [InlineData("--env-file=**")] + [InlineData("--env-file=.env\n!.env")] + public async Task VerifyDockerfile_EnvironmentFileIsRejected(string runtimeArg) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var app = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoRuntimeArgs(runtimeArg); + + var exception = await Assert.ThrowsAsync( + () => ManifestUtils.GetManifest(app.Resource, workspace.Path)); + Assert.Equal( + "Generated Deno Dockerfiles do not support '--env-file' because dotenv files can contain secrets that would be copied into the container image. Use Aspire environment variables or secret parameters, or provide a custom Dockerfile that handles the file securely.", + exception.Message); + } + + [Fact] + public async Task VerifyDockerfile_EnvironmentFileIsAllowedWithCustomDockerfile() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "Dockerfile"), "FROM denoland/deno:2.9.0"); + + var app = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoRuntimeArgs("--env-file=.env.production"); + + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + Assert.False(File.Exists(Path.Combine(workspace.Path, "js.Dockerfile"))); + } + + [Fact] + public async Task VerifyDockerfile_CachePropagatesResolutionGoverningRuntimeArgs() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "main.ts"), "console.log(1);"); + + var app = builder.AddDenoApp("js", appDir, "main.ts") + // --cert is required to fetch a private HTTPS module and --no-remote forbids network resolution. + // Both are honoured by `deno cache`, so dropping them either breaks `docker build` or silently + // ignores the caller's policy at build time. + .WithDenoRuntimeArgs("--allow-net", "--cert", "ca.pem", "--no-remote", "--vendor=true"); + + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + // --allow-net is a run-only permission that `deno cache` rejects outright, so it must not be forwarded. + Assert.Equal( + "RUN deno cache --cert ca.pem --no-remote --vendor=true main.ts", + GetDockerfileLine(dockerfileContents, "RUN deno cache")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task VerifyDockerfile_CertificatePathsAreNormalizedForLinuxContainers(bool inlineValue) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "main.ts"), "console.log(1);"); + + var app = builder.AddDenoApp("js", appDir, "main.ts"); + if (inlineValue) + { + app.WithDenoRuntimeArgs(@"--cert=certs\ca.pem"); + } + else + { + app.WithDenoRuntimeArgs("--cert", @"certs\ca.pem"); + } + + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + if (inlineValue) + { + Assert.Equal( + "RUN deno cache --cert=certs/ca.pem main.ts", + GetDockerfileLine(dockerfileContents, "RUN deno cache")); + Assert.Equal( + """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--cached-only","--cert=certs/ca.pem","main.ts"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + else + { + Assert.Equal( + "RUN deno cache --cert certs/ca.pem main.ts", + GetDockerfileLine(dockerfileContents, "RUN deno cache")); + Assert.Equal( + """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--cached-only","--cert","certs/ca.pem","main.ts"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + } + + [Fact] + public async Task VerifyDockerfile_CacheOmitsRuntimeArgsDenoCacheRejects() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "main.ts"), "console.log(1);"); + + var app = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoRuntimeArgs("-A", "--allow-read", "/data", "--cached-only"); + + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + // Forwarding these would fail the build: `deno cache -A` reports "error: unexpected argument". + // "/data" is a bare value belonging to --allow-read and must not leak through as a module to cache. + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal("RUN deno cache main.ts", GetDockerfileLine(dockerfileContents, "RUN deno cache")); + } + + [Fact] + public async Task VerifyDockerfile_CacheOmitsTrailingValueFlagThatWouldSwallowTheEntrypoint() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "main.ts"), "console.log(1);"); + + var app = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoRuntimeArgs("--lock"); + + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + // `deno cache --lock main.ts` consumes main.ts as the lock file and then fails with + // "the following required arguments were not provided: ...", so a bare trailing --lock is dropped. + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal("RUN deno cache main.ts", GetDockerfileLine(dockerfileContents, "RUN deno cache")); + } + + [Fact] + public async Task VerifyDockerfile_BuildScriptWithSpacesIsQuotedForDeno() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"build prod":"deno run -A build.ts","start":"deno run -A main.ts"}}"""); + + var app = builder.AddJavaScriptApp("js", appDir) + .WithDeno() + .WithBuildScript("build prod") + .PublishAsPackageScript("start"); + + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + // Without quoting the shell word-splits this into `deno task build` plus a stray `prod` argument. + Assert.Equal("RUN deno task 'build prod'", GetDockerfileLine(dockerfileContents, "RUN deno task")); + } + + [Fact] + public async Task VerifyDockerfile_BuildScriptWithSpacesIsQuotedForNpm() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "package.json"), """{"scripts":{"build prod":"echo build","start":"node main.js"}}"""); + + var app = builder.AddJavaScriptApp("js", appDir) + .WithNpm() + .WithBuildScript("build prod") + .PublishAsPackageScript("start"); + + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + // The build-script RUN line is shared by every package manager, so the quoting fix must hold off the Deno path too. + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal("RUN npm run 'build prod'", GetDockerfileLine(dockerfileContents, "RUN npm run")); + } + + [Fact] + public async Task VerifyDockerfile_PublishAsPackageScriptWithCustomDenoRuntimeImagePreservesImageUser() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"start":"deno run -A main.ts"}}"""); + + var app = builder.AddJavaScriptApp("js", appDir) + .WithDockerfileBaseImage("denoland/deno:2.9.0", "denoland/deno:2.1-distroless") + .WithDeno() + .PublishAsPackageScript("start"); + + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal("COPY --from=build /app /app", GetDockerfileLine(dockerfileContents, "COPY --from=build /app")); + Assert.Equal("COPY --from=build /deno-dir /deno-dir", GetDockerfileLine(dockerfileContents, "COPY --from=build /deno-dir")); + Assert.DoesNotContain(dockerfileContents.Split(Environment.NewLine), line => line.StartsWith("USER ", StringComparison.Ordinal)); + // Distroless images have no /bin/sh, so the entrypoint must be exec form. + Assert.Equal( + """ENTRYPOINT ["deno","task","start"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task VerifyDockerfile_RuntimeOnlyBaseImageOverrideStillBuildsWithDeno(bool denoBeforeOverride) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"start":"deno run -A main.ts"}}"""); + + // WithDockerfileBaseImage replaces the annotation, so a runtime-only override leaves BuildImage null in + // either ordering. The build stage must still resolve to a Deno image or `deno install` would not exist. + var app = builder.AddJavaScriptApp("js", appDir); + if (denoBeforeOverride) + { + app.WithDeno(install: true).WithDockerfileBaseImage(runtimeImage: "denoland/deno:2.1-distroless"); + } + else + { + app.WithDockerfileBaseImage(runtimeImage: "denoland/deno:2.1-distroless").WithDeno(install: true); + } + + app.PublishAsPackageScript("start"); + + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal("FROM denoland/deno:2.9.0 AS build", GetDockerfileLine(dockerfileContents, "FROM denoland/deno:2.9.0")); + Assert.Equal("FROM denoland/deno:2.1-distroless AS runtime", GetDockerfileLine(dockerfileContents, "FROM denoland/deno:2.1-distroless")); + } + + [Fact] + public async Task VerifyDockerfile_PublishAsPackageScriptWithPlainArgumentsUsesExecForm() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"start":"deno run -A main.ts"}}"""); + + var app = builder.AddJavaScriptApp("js", appDir) + .WithDeno() + .PublishAsPackageScript("start", """-- --port 8080 --name "my app" """); + + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal( + """ENTRYPOINT ["deno","task","start","--","--port","8080","--name","my app"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + // Every character the classifier treats as inert is baked into an exec-form ENTRYPOINT verbatim, so a + // character that actually means something to `sh` would silently change the command. These two theories + // pin the boundary; the shell-routed set was verified against a real /bin/sh. + [Theory] + [InlineData("--port 8080", new[] { "--port", "8080" })] + [InlineData("--name 'my app'", new[] { "--name", "my app" })] + [InlineData("""--path "/a b" """, new[] { "--path", "/a b" })] + [InlineData("--flag=a,b", new[] { "--flag=a,b" })] + [InlineData("--email a@b.co", new[] { "--email", "a@b.co" })] + [InlineData("--pct 50%", new[] { "--pct", "50%" })] + [InlineData("--not !x", new[] { "--not", "!x" })] + [InlineData("--caret a^b", new[] { "--caret", "a^b" })] + [InlineData("--plus a+b", new[] { "--plus", "a+b" })] + public void BuildDenoPackageScriptEntrypoint_KeepsShellInertArgumentsInExecForm(string runScriptArguments, string[] expectedArgumentTokens) + { + var entrypoint = JavaScriptHostingExtensions.BuildDenoPackageScriptEntrypoint("deno", "task", "start", runScriptArguments); + + Assert.Equal(["deno", "task", "start", .. expectedArgumentTokens], entrypoint); + } + + [Theory] + // Expansion, substitution, operators, and redirection. + [InlineData("--port $PORT")] + [InlineData("--rev `git rev-parse HEAD`")] + [InlineData("--a x|y")] + [InlineData("--a x&y")] + [InlineData("--a x;y")] + [InlineData("--out >log")] + [InlineData("--in ( + () => JavaScriptHostingExtensions.BuildDenoPackageScriptEntrypoint("deno", "task", "start", runScriptArguments)); + + Assert.Equal( + $"The Deno run script arguments '{runScriptArguments}' contain an unterminated {quoteKind} quote. Close the quote so the arguments can be parsed the way a shell would parse them.", + exception.Message); + } + + [Theory] + [InlineData("--name 'closed'", new[] { "deno", "task", "start", "--name", "closed" })] + [InlineData("--name \"closed\"", new[] { "deno", "task", "start", "--name", "closed" })] + [InlineData("''", new[] { "deno", "task", "start", "" })] + public void BuildDenoPackageScriptEntrypoint_AcceptsBalancedQuotes(string runScriptArguments, string[] expected) + { + var entrypoint = JavaScriptHostingExtensions.BuildDenoPackageScriptEntrypoint("deno", "task", "start", runScriptArguments); + + Assert.Equal(expected, entrypoint); + } + + [Fact] + public void BuildDenoPackageScriptEntrypoint_QuotesCommandComponentsInShellForm() + { + // Only runScriptArguments is meant to be shell-evaluated. A task name with a space would otherwise + // word-split into `deno task build prod`, running the "build" task with a stray "prod" argument. + var entrypoint = JavaScriptHostingExtensions.BuildDenoPackageScriptEntrypoint("deno", "task", "build prod", "-- --port $PORT"); + + Assert.Equal(["sh", "-c", "exec deno task 'build prod' -- --port $PORT"], entrypoint); + } + + [Fact] + public async Task VerifyDockerfile_PublishAsPackageScriptPreservesPosixDoubleQuoteEscapes() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"start":"deno run -A main.ts"}}"""); + + // Inside double quotes POSIX only treats a backslash as an escape before $ ` " \ and newline, so + // "\d+" must survive intact while "C:\\tmp" collapses to a single backslash. + var app = builder.AddJavaScriptApp("js", appDir) + .WithDeno() + .PublishAsPackageScript("start", """-- --pattern "\d+" --path "C:\\tmp" --quote "say \"hi\"" """); + + await ManifestUtils.GetManifest(app.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal( + """ENTRYPOINT ["deno","task","start","--","--pattern","\\d+","--path","C:\\tmp","--quote","say \"hi\""]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Fact] + public async Task VerifyDockerfile_CacheUsesNoLockWhenConfigured() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "deno.lock"), "{}"); + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoNoLock(); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal("RUN deno cache --no-lock main.ts", GetDockerfileLine(dockerfileContents, "RUN deno cache")); + } + + [Fact] + public async Task VerifyDockerfile_CacheDoesNotCombineRawNoLockWithFrozen() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "deno.lock"), "{}"); + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoRuntimeArgs("--no-lock"); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal("RUN deno cache --no-lock main.ts", GetDockerfileLine(dockerfileContents, "RUN deno cache")); + } + + [Fact] + public async Task VerifyDockerfile_ManualNodeModulesDirThrows() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var denoApp = builder.AddDenoApp("denoapp", workspace.Path, "main.ts") + .WithDenoNodeModulesDir(DenoNodeModulesDirMode.Manual); + + var exception = await Assert.ThrowsAsync(() => ManifestUtils.GetManifest(denoApp.Resource, workspace.Path)); + + Assert.Equal("The 'manual' node_modules mode is not supported by generated Deno Dockerfiles because node_modules is excluded from the build context. Use the 'auto' mode or provide a custom Dockerfile.", exception.Message); + } + + [Fact] + public async Task VerifyDockerfile_AlternatePackageManagerThrows() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var denoApp = builder.AddDenoApp("denoapp", workspace.Path, "main.ts") + .WithRunScript("start") + .WithNpm(); + + var exception = await Assert.ThrowsAsync(() => ManifestUtils.GetManifest(denoApp.Resource, workspace.Path)); + + Assert.Equal("Generated Deno Dockerfiles do not support alternate package manager 'npm'. Use WithDeno() or provide a custom Dockerfile.", exception.Message); + } + + [Fact] + public async Task VerifyDockerfile_CacheUsesUnstableFlags() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoConfig("deno.json") + .WithDenoUnstable("sloppy-imports"); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal( + "RUN deno cache --config deno.json --unstable-sloppy-imports main.ts", + GetDockerfileLine(dockerfileContents, "RUN deno cache")); + } + + [Fact] + public async Task VerifyDockerfile_CacheQuotesShellArguments() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "custom lock's.lock"), "{}"); + + var denoApp = builder.AddDenoApp("js", appDir, "main file's.ts") + .WithDenoConfig("deno config.json") + .WithDenoImportMap("import map.json") + .WithDenoLock("custom lock's.lock"); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal( + """RUN deno cache --config 'deno config.json' --import-map 'import map.json' --lock 'custom lock'"'"'s.lock' --frozen 'main file'"'"'s.ts'""", + GetDockerfileLine(dockerfileContents, "RUN deno cache")); + } + + [Fact] + public async Task VerifyDockerfile_EntrypointDoesNotIncludeDevelopmentFlags() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoWatch() + .WithDenoInspect(DenoInspectMode.InspectWait, "127.0.0.1:9229"); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal("""ENTRYPOINT ["deno","run","--allow-net","--allow-env","--cached-only","main.ts"]""", GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + // --cached-only is an Aspire default, not a hard requirement. Deno accepts it alongside --reload without + // error but --cached-only silently wins, so emitting both would turn the caller's explicit cache policy + // into a no-op. Drop the default instead of overriding the caller. + [Theory] + [InlineData("--reload")] + [InlineData("--reload=npm:chalk")] + [InlineData("-r")] + [InlineData("--cached-only")] + public async Task VerifyDockerfile_EntrypointDropsManagedCachedOnlyWhenRuntimeArgsSelectACachePolicy(string cacheFlag) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoRuntimeArgs(cacheFlag); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal($$"""ENTRYPOINT ["deno","run","--allow-net","--allow-env","{{cacheFlag}}","main.ts"]""", GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Fact] + public async Task VerifyDockerfile_ServeEntrypointBindsEndpointTargetPort() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var denoApp = builder.AddDenoApp("js", appDir, "server.ts") + .WithDenoServe() + .WithHttpEndpoint(targetPort: 5173); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal( + """ENTRYPOINT ["deno","serve","--allow-net","--allow-env","--cached-only","--host","0.0.0.0","--port","5173","server.ts"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Fact] + public async Task VerifyDockerfile_ServeEntrypointPreservesPreconfiguredEndpointTargetPort() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var denoApp = builder.AddDenoApp("js", appDir, "server.ts") + .WithHttpEndpoint(targetPort: 5173) + .WithDenoServe(); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal( + """ENTRYPOINT ["deno","serve","--allow-net","--allow-env","--cached-only","--host","0.0.0.0","--port","5173","server.ts"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Fact] + public async Task VerifyDockerfile_ServeDefaultEndpointPinsDenoDefaultPort() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var denoApp = builder.AddDenoApp("js", appDir, "server.ts") + .WithDenoServe(); + + var httpEndpoint = denoApp.Resource.GetEndpoint("http"); + Assert.Equal(8000, httpEndpoint.EndpointAnnotation.TargetPort); + + var manifest = await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + Assert.Equal(8000, manifest["bindings"]!["http"]!["targetPort"]!.GetValue()); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal( + """ENTRYPOINT ["deno","serve","--allow-net","--allow-env","--cached-only","--host","0.0.0.0","--port","8000","server.ts"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Fact] + public void WithDenoServe_DoesNotPinRunModeDefaultTargetPort() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var denoApp = builder.AddDenoApp("js", appDir, "server.ts") + .WithDenoServe(); + + Assert.Null(denoApp.Resource.GetEndpoint("http").EndpointAnnotation.TargetPort); + } + + [Theory] + [InlineData("task")] + [InlineData("run")] + public async Task WithDenoServe_WithdrawsHttpEndpointAndEnvironmentWhenAnotherModeWins(string finalMode) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true); + builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://localhost:4318"; + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var denoApp = builder.AddDenoApp("js", appDir, "server.ts"); + var initialEnvironmentCallbackCount = denoApp.Resource.Annotations.OfType().Count(); + + denoApp.WithDenoServe(); + _ = finalMode == "task" ? denoApp.WithDenoTask("worker") : denoApp.WithDenoRun(); + + // A non-serve mode does not bind HTTP, so leaving the endpoint behind would advertise a binding through + // service discovery for a process that never listens, and would still inject PORT. + Assert.Empty(denoApp.Resource.Annotations.OfType()); + Assert.Equal(initialEnvironmentCallbackCount, denoApp.Resource.Annotations.OfType().Count()); + + var environment = await EnvironmentVariableEvaluator + .GetEnvironmentVariablesAsync(denoApp.Resource, DistributedApplicationOperation.Run) + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Null(environment.GetValueOrDefault("PORT")); + } + + [Fact] + public async Task WithDenoServe_RestoresHttpEndpointAndEnvironmentWhenServeWinsAgain() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var denoApp = builder.AddDenoApp("js", appDir, "server.ts") + .WithDenoServe() + .WithDenoTask("worker") + .WithDenoServe(); + + var endpoint = Assert.Single(denoApp.Resource.Annotations.OfType()); + Assert.Equal("http", endpoint.Name); + + var environment = await EnvironmentVariableEvaluator + .GetEnvironmentVariablesAsync(denoApp.Resource, DistributedApplicationOperation.Publish) + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal( + [ + new("NODE_ENV", "production"), + new("PORT", "{js.bindings.http.targetPort}"), + ], + environment.OrderBy(pair => pair.Key)); + } + + [Fact] + public void WithDenoTask_KeepsCallerConfiguredHttpEndpoint() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + // The endpoint here is the caller's, not one WithDenoServe created, so switching modes must not remove it. + var denoApp = builder.AddDenoApp("js", appDir, "server.ts") + .WithHttpEndpoint(name: "api") + .WithDenoTask("worker"); + + var endpoint = Assert.Single(denoApp.Resource.Annotations.OfType()); + Assert.Equal("api", endpoint.Name); + } + + [Fact] + public async Task WithDenoTask_RestoresCallerConfiguredSameNameHttpEndpoint() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true); + builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://localhost:4318"; + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var denoApp = builder.AddDenoApp("js", appDir, "server.ts") + .WithHttpEndpoint(targetPort: 4321, env: "APP_PORT"); + var callerEndpoint = denoApp.Resource.GetEndpoint("http").EndpointAnnotation; + var initialEnvironmentCallbackCount = denoApp.Resource.Annotations.OfType().Count(); + + denoApp.WithDenoServe().WithDenoTask("worker"); + + var endpoint = Assert.Single(denoApp.Resource.Annotations.OfType()); + Assert.Same(callerEndpoint, endpoint); + Assert.Equal(4321, endpoint.TargetPort); + Assert.Equal(initialEnvironmentCallbackCount, denoApp.Resource.Annotations.OfType().Count()); + + var environment = await EnvironmentVariableEvaluator + .GetEnvironmentVariablesAsync(denoApp.Resource, DistributedApplicationOperation.Run) + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal("4321", environment["APP_PORT"]); + Assert.Null(environment.GetValueOrDefault("PORT")); + } + + [Fact] + public async Task VerifyDockerfile_ServeResourcesReuseProcessLocalDefaultTargetPort() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir1 = Path.Combine(workspace.Path, "js1"); + var appDir2 = Path.Combine(workspace.Path, "js2"); + Directory.CreateDirectory(appDir1); + Directory.CreateDirectory(appDir2); + + _ = builder.AddDenoApp("js1", appDir1, "server.ts") + .WithDenoServe(); + var denoApp2 = builder.AddDenoApp("js2", appDir2, "server.ts") + .WithDenoServe(); + + var httpEndpoint = denoApp2.Resource.GetEndpoint("http"); + Assert.Equal(8000, httpEndpoint.EndpointAnnotation.TargetPort); + + await ManifestUtils.GetManifest(denoApp2.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js2.Dockerfile")); + Assert.Equal( + """ENTRYPOINT ["deno","serve","--allow-net","--allow-env","--cached-only","--host","0.0.0.0","--port","8000","server.ts"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Fact] + public void AddDenoApp_DoesNotAddDenoPackageManagerWhenNoManifest() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, "main.ts"), "console.log('hi');"); + + var builder = DistributedApplication.CreateBuilder(); + + builder.AddDenoApp("denoapp", workspace.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 workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, "package.json"), "{}"); + + var builder = DistributedApplication.CreateBuilder(); + + builder.AddDenoApp("denoapp", workspace.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 workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, "deno.json"), "{}"); + + var builder = DistributedApplication.CreateBuilder(); + + builder.AddDenoApp("denoapp", workspace.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 workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, "main.ts"), "console.log('hi');"); + + var builder = DistributedApplication.CreateBuilder(); + builder.AddDenoApp("denoapp", workspace.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 async Task WithRunScript_ComposesWithDenoFlagsAsTaskCommand() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddDenoApp("denoapp", ".", "main.ts") + .WithDeno() + .WithRunScript("start", ["--my-arg1"]) + .WithDenoConfig("deno.json") + .WithDenoImportMap("import_map.json") + .WithDenoLock("deno.lock") + .WithDenoAllow(DenoPermissionKind.Net, "localhost"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var denoResource = Assert.Single(appModel.Resources.OfType()); + var args = await ArgumentEvaluator.GetArgumentListAsync(denoResource); + + // WithRunScript is still a Deno task launch when additional WithDeno* flags are added. + // Task mode keeps valid task-level resolution flags and leaves permissions/import maps to the task body. + Assert.Collection(args, + arg => Assert.Equal("task", arg), + arg => Assert.Equal("--config", arg), + arg => Assert.Equal("deno.json", arg), + arg => Assert.Equal("--lock", arg), + arg => Assert.Equal("deno.lock", arg), + arg => Assert.Equal("start", arg), + arg => Assert.Equal("--my-arg1", arg)); + } + + [Fact] + public async Task WithRunScript_ExplicitDenoRunUsesEntrypoint() + { + var args = await GetDenoArgsAsync(d => d + .WithDeno() + .WithRunScript("start", ["--my-arg1"]) + .WithDenoRun() + .WithDenoAllowAll(false) + .WithDenoConfig("deno.json")); + + Assert.Collection(args, + arg => Assert.Equal("run", arg), + arg => Assert.Equal("--config", arg), + arg => Assert.Equal("deno.json", arg), + arg => Assert.Equal("main.ts", arg)); + } + + [Fact] + public void WithDenoAllow_NullBuilderThrowsArgumentNullException() + { + IResourceBuilder builder = null!; + + var exception = Assert.Throws(() => builder.WithDenoAllow(DenoPermissionKind.Net)); + + Assert.Equal("builder", exception.ParamName); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void WithDenoPermission_RejectsUndefinedKind(bool deny) + { + using var builder = TestDistributedApplicationBuilder.Create(); + var denoApp = builder.AddDenoApp("denoapp", AppContext.BaseDirectory, "main.ts"); + + var exception = Assert.Throws(() => + { + if (deny) + { + denoApp.WithDenoDeny((DenoPermissionKind)42); + } + else + { + denoApp.WithDenoAllow((DenoPermissionKind)42); + } + }); + + Assert.Equal("kind", exception.ParamName); + Assert.Equal( + "The permission kind must be a defined DenoPermissionKind value. (Parameter 'kind')\nActual value was 42.", + exception.Message.ReplaceLineEndings("\n")); + } + + [Theory] + [InlineData(false, null)] + [InlineData(true, "")] + public void WithDenoPermission_RejectsNullOrEmptyValues(bool deny, string? value) + { + using var builder = TestDistributedApplicationBuilder.Create(); + var denoApp = builder.AddDenoApp("denoapp", AppContext.BaseDirectory, "main.ts"); + + var exception = Assert.Throws(() => + { + if (deny) + { + denoApp.WithDenoDeny(DenoPermissionKind.Read, value!); + } + else + { + denoApp.WithDenoAllow(DenoPermissionKind.Read, value!); + } + }); + + Assert.Equal( + "Deno permission values cannot be null or empty. (Parameter 'values')", + exception.Message); + } + + [Fact] + public async Task WithDenoPermission_PreservesWhitespaceValue() + { + var args = await GetDenoArgsAsync(d => d.WithDenoAllow(DenoPermissionKind.Read, " ")); + + Assert.Equal(["run", "--allow-read= ", "main.ts"], args); + } + + [Fact] + public void WithDenoInstallFalseDoesNotCreateInstallerWhenNoneExists() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + builder.AddDenoApp("denoapp", workspace.Path, "main.ts") + .WithDeno(install: false); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + Assert.Empty(appModel.Resources.OfType()); + } + + [Fact] + public void WithDenoInstallTrueCreatesInstaller() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var app = builder.AddDenoApp("denoapp", workspace.Path, "main.ts") + .WithDeno(install: true); + + using var distributedApplication = builder.Build(); + var appModel = distributedApplication.Services.GetRequiredService(); + var installer = Assert.Single(appModel.Resources.OfType()); + + Assert.False(installer.TryGetLastAnnotation(out _)); + Assert.Contains(app.Resource.Annotations.OfType(), wait => wait.Resource == installer); + } + + [Fact] + public void WithDenoInstallFalseDisablesExistingInstaller() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var app = builder.AddJavaScriptApp("js", workspace.Path) + .WithDeno(install: false); + + using var distributedApplication = builder.Build(); + var appModel = distributedApplication.Services.GetRequiredService(); + var installer = Assert.Single(appModel.Resources.OfType()); + + Assert.True(installer.TryGetLastAnnotation(out _)); + Assert.DoesNotContain(app.Resource.Annotations.OfType(), wait => wait.Resource == installer); + } + + [Fact] + public void WithDenoInstallTrueReEnablesDisabledInstaller() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var app = builder.AddJavaScriptApp("js", workspace.Path) + .WithDeno(install: false) + .WithDeno(install: true); + + using var distributedApplication = builder.Build(); + var appModel = distributedApplication.Services.GetRequiredService(); + var installer = Assert.Single(appModel.Resources.OfType()); + + Assert.False(installer.TryGetLastAnnotation(out _)); + Assert.Single(app.Resource.Annotations.OfType(), wait => wait.Resource == installer); + } + + [Fact] + public async Task DenoOptionsWithAlternatePackageManagerThrows() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + builder.AddDenoApp("denoapp", workspace.Path, "main.ts") + .WithRunScript("start") + .WithNpm() + .WithDenoWatch(); + + using var app = builder.Build(); + + var exception = await Assert.ThrowsAsync(() => app.StartAsync()); + Assert.Equal( + "Deno command-line options configured with the WithDeno* methods cannot be combined with package manager 'npm' on resource 'denoapp'. Remove the WithDeno* options or use WithDeno().", + exception.Message); + } + + [Fact] + public async Task VerifyDockerfile_NormalizesHostPathSeparatorsForContainerPaths() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + // Windows AppHosts configure nested paths with backslashes; the Linux build/runtime stages need POSIX form. + var denoApp = builder.AddDenoApp("js", appDir, @"src\main.ts") + .WithDenoConfig(@"config\deno.json"); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal( + "RUN deno cache --config config/deno.json src/main.ts", + GetDockerfileLine(dockerfileContents, "RUN deno cache")); + Assert.Equal( + """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--config","config/deno.json","--cached-only","src/main.ts"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Fact] + public async Task VerifyDockerfile_NormalizesPathsThatStayWithinBuildContext() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var denoApp = builder.AddDenoApp("js", appDir, @"src\..\main.ts") + .WithDenoConfig("config/../deno.json") + .WithDenoImportMap(@"maps\..\import_map.json") + .WithDenoLock("locks/../deno.lock"); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal( + "RUN deno cache --config deno.json --import-map import_map.json --lock deno.lock main.ts", + GetDockerfileLine(dockerfileContents, "RUN deno cache")); + Assert.Equal( + """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--config","deno.json","--import-map","import_map.json","--lock","deno.lock","--cached-only","main.ts"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Theory] + [InlineData("-c", "config/../deno.json", "RUN deno cache -c deno.json main.ts", """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--cached-only","-c","deno.json","main.ts"]""")] + [InlineData("-c=config/../deno.json", null, "RUN deno cache -c=deno.json main.ts", """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--cached-only","-c=deno.json","main.ts"]""")] + public async Task VerifyDockerfile_NormalizesConfigAliasPath(string flag, string? value, string expectedCacheCommand, string expectedEntrypoint) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + string[] runtimeArgs = value is null ? [flag] : [flag, value]; + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoRuntimeArgs(runtimeArgs); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal(expectedCacheCommand, GetDockerfileLine(dockerfileContents, "RUN deno cache")); + Assert.Equal(expectedEntrypoint, GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Fact] + public async Task VerifyDockerfile_PreservesRemoteImportMapUrl() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoImportMap("https://example.com/import_map.json"); + + await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path); + + var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")); + Assert.Equal( + "RUN deno cache --import-map https://example.com/import_map.json main.ts", + GetDockerfileLine(dockerfileContents, "RUN deno cache")); + Assert.Equal( + """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--import-map","https://example.com/import_map.json","--cached-only","main.ts"]""", + GetDockerfileLine(dockerfileContents, "ENTRYPOINT")); + } + + [Theory] + [InlineData("../shared/deno.json")] + [InlineData("/etc/deno.json")] + [InlineData("..")] + // Traversal embedded mid-path still resolves outside the build context. + [InlineData("config/../../outside.json")] + // ToDenoContainerPath rewrites backslashes, so this becomes the absolute container path /tmp/deno.json + // even though Path.IsPathRooted reports false for it on Linux and macOS. + [InlineData("\\tmp\\deno.json")] + [InlineData("\\\\server\\share\\deno.json")] + [InlineData("C:\\temp\\deno.json")] + [InlineData("C:/temp/deno.json")] + [InlineData("C:temp\\deno.json")] + public async Task VerifyDockerfile_RejectsConfigPathOutsideBuildContext(string configFile) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(workspace.Path, "js"); + Directory.CreateDirectory(appDir); + + var denoApp = builder.AddDenoApp("js", appDir, "main.ts") + .WithDenoConfig(configFile); + + var exception = await Assert.ThrowsAsync( + () => ManifestUtils.GetManifest(denoApp.Resource, workspace.Path)); + Assert.Equal( + $"The path '{configFile}' configured with WithDenoConfig is outside the Deno application directory, so it is not part of the generated Dockerfile's build context. Move the file inside the application directory or provide a custom Dockerfile.", + exception.Message); + } + + // Helper: build a Deno resource, apply the given flag configuration, and evaluate the emitted argument list. + private static async Task> GetDenoArgsAsync(Action> configure, string entrypoint = "main.ts") + { + var builder = DistributedApplication.CreateBuilder(); + var deno = builder.AddDenoApp("denoapp", ".", entrypoint); + configure(deno); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + var denoResource = Assert.Single(appModel.Resources.OfType()); + + return await ArgumentEvaluator.GetArgumentListAsync(denoResource); + } + + [Theory] + [InlineData("--port", "3000")] + [InlineData("--port=3000", null)] + [InlineData("--host", "127.0.0.1")] + [InlineData("--host=127.0.0.1", null)] + public async Task WithDenoServe_RuntimeArgsOverridingHostOrPort_Throws(string flag, string? value) + { + // Deno rejects a repeated --host/--port ("cannot be used multiple times"), and serve mode always emits + // the managed pair, so the resource would fail to start with a raw Deno parser error. + string[] runtimeArgs = value is null ? [flag] : [flag, value]; + + var ex = await Assert.ThrowsAsync( + () => GetDenoArgsAsync(d => d.WithDenoServe().WithDenoRuntimeArgs(runtimeArgs))); + + Assert.Equal( + $"The argument '{flag}' cannot be configured with WithDenoRuntimeArgs because WithDenoServe already emits --host and --port from the resource's endpoint, and Deno rejects those arguments when they are combined. Configure the endpoint instead, for example WithHttpEndpoint(port: 5005).", + ex.Message); + } + + // Deno hard-errors when a single-occurrence option is repeated ("cannot be used multiple times") or when + // mutually exclusive flags are combined ("cannot be used with"), verified on 2.9.0. Both are clap errors + // that never mention Aspire, so every managed flag must be guarded, not just the serve endpoint pair. + [Theory] + [InlineData("--config", "other.json")] + [InlineData("--config=other.json", null)] + [InlineData("-c", "other.json")] + [InlineData("-c=other.json", null)] + [InlineData("--no-config", null)] + [InlineData("--import-map", "other.json")] + [InlineData("--import-map=other.json", null)] + [InlineData("--lock", "other.lock")] + [InlineData("--lock=other.lock", null)] + [InlineData("--no-lock", null)] + [InlineData("--node-modules-dir", "none")] + [InlineData("--node-modules-dir=none", null)] + [InlineData("--watch", null)] + [InlineData("--watch-hmr", null)] + [InlineData("--inspect", null)] + [InlineData("--inspect=127.0.0.1:9230", null)] + public async Task WithDenoRuntimeArgs_DuplicatingAManagedFlag_Throws(string flag, string? value) + { + string[] runtimeArgs = value is null ? [flag] : [flag, value]; + + var ex = await Assert.ThrowsAsync( + () => GetDenoArgsAsync(d => d + .WithDenoConfig("deno.json") + .WithDenoImportMap("import_map.json") + .WithDenoLock("deno.lock") + .WithDenoNodeModulesDir() + .WithDenoWatch() + .WithDenoInspect() + .WithDenoRuntimeArgs(runtimeArgs))); + + Assert.StartsWith($"The argument '{flag}' cannot be configured with WithDenoRuntimeArgs because ", ex.Message, StringComparison.Ordinal); + Assert.EndsWith(" instead.", ex.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("--config", "other.json")] + [InlineData("--config=other.json", null)] + [InlineData("-c", "other.json")] + [InlineData("-c=other.json", null)] + [InlineData("--no-config", null)] + public async Task WithDenoRuntimeArgs_ConflictingConfigSpellingsThrowWithActionableGuidance(string flag, string? value) + { + string[] runtimeArgs = value is null ? [flag] : [flag, value]; + + var ex = await Assert.ThrowsAsync( + () => GetDenoArgsAsync(d => d + .WithDenoConfig("deno.json") + .WithDenoRuntimeArgs(runtimeArgs))); + + Assert.Equal( + $"The argument '{flag}' cannot be configured with WithDenoRuntimeArgs because WithDenoConfig already emits --config, and Deno rejects those arguments when they are combined. Pass the configuration file to WithDenoConfig instead.", + ex.Message); + } + + [Fact] + public async Task WithDenoRuntimeArgs_LockAndNoLockAreMutuallyExclusiveWithTheManagedFlag() + { + var ex = await Assert.ThrowsAsync( + () => GetDenoArgsAsync(d => d + .WithDenoNoLock() + .WithDenoRuntimeArgs("--lock", "other.lock"))); + + Assert.Equal( + "The argument '--lock' cannot be configured with WithDenoRuntimeArgs because WithDenoNoLock already emits --no-lock, and Deno rejects those arguments when they are combined. Configure locking with WithDenoNoLock instead.", + ex.Message); + } + + [Theory] + // Repeatable options merge in Deno, so layering extra grants over the managed ones must keep working. + [InlineData("--allow-read", "/var")] + [InlineData("--allow-net", "example.com")] + [InlineData("--parallel", null)] + [InlineData("--v8-flags=--max-old-space-size=4096", null)] + public async Task WithDenoRuntimeArgs_NonConflictingArgumentsArePreserved(string flag, string? value) + { + string[] runtimeArgs = value is null ? [flag] : [flag, value]; + + var args = await GetDenoArgsAsync(d => d + .WithDenoAllow(DenoPermissionKind.Read, "/etc/app") + .WithDenoConfig("deno.json") + .WithDenoRuntimeArgs(runtimeArgs)); + + Assert.Equal(["run", "--allow-read=/etc/app", "--config", "deno.json", .. runtimeArgs, "main.ts"], args); + } + + // Task mode resolves configuration from deno.json and never emits --import-map or the development-only + // watch/inspect flags, so those must stay available through the escape hatch. + [Fact] + public async Task WithDenoTask_RuntimeArgsForFlagsTaskModeDoesNotEmitArePreserved() + { + var args = await GetDenoArgsAsync(d => d + .WithDenoTask("start") + .WithDenoWatch() + .WithDenoRuntimeArgs("--import-map", "import_map.json")); + + Assert.Equal(["task", "--import-map", "import_map.json", "start"], args); + } + + [Fact] + public async Task WithDenoTask_RuntimeArgsDuplicatingAManagedTaskFlag_Throws() + { + var ex = await Assert.ThrowsAsync( + () => GetDenoArgsAsync(d => d + .WithDenoTask("start") + .WithDenoConfig("deno.json") + .WithDenoRuntimeArgs("--config", "other.json"))); + + Assert.Equal( + "The argument '--config' cannot be configured with WithDenoRuntimeArgs because WithDenoConfig already emits --config, and Deno rejects those arguments when they are combined. Pass the configuration file to WithDenoConfig instead.", + ex.Message); + } + + [Fact] + public async Task WithDenoServe_UnrelatedRuntimeArgsArePreserved() + { + var args = await GetDenoArgsAsync(d => d + .WithDenoServe() + .WithHttpEndpoint(targetPort: 5173) + .WithDenoRuntimeArgs("--parallel"), entrypoint: "server.ts"); + + // Runtime args are appended after the managed --host/--port pair, before the entrypoint. + Assert.Collection(args, + a => Assert.Equal("serve", a), + a => Assert.Equal("-A", a), + a => Assert.Equal("--host", a), + a => Assert.Equal("localhost", a), + a => Assert.Equal("--port", a), + a => Assert.Equal("5173", a), + a => Assert.Equal("--parallel", a), + a => Assert.Equal("server.ts", a)); + } + + [Fact] + public async Task WithDenoWatch_HmrAndPlainAreMutuallyExclusive() + { + // Deno rejects "--watch-hmr" combined with "--watch", so only the last selection may be emitted. + var hmrLast = await GetDenoArgsAsync(d => d.WithDenoWatch().WithDenoWatch(hmr: true)); + + Assert.Collection(hmrLast, + a => Assert.Equal("run", a), + a => Assert.Equal("-A", a), + a => Assert.Equal("--watch-hmr", a), + a => Assert.Equal("main.ts", a)); + + var plainLast = await GetDenoArgsAsync(d => d.WithDenoWatch(hmr: true).WithDenoWatch()); + + Assert.Collection(plainLast, + a => Assert.Equal("run", a), + a => Assert.Equal("-A", a), + a => Assert.Equal("--watch", a), + a => Assert.Equal("main.ts", a)); + } + + [Fact] + public async Task WithDenoPermission_MutatingTheCallerArrayAfterTheCallDoesNotChangeTheCommandLine() + { + // Permission values are only read when the command line is materialized, so holding the caller's + // params array by reference would let a later mutation silently rewrite the launch arguments. + var hosts = new[] { "localhost", "api.internal" }; + + var args = await GetDenoArgsAsync(d => + { + d.WithDenoAllow(DenoPermissionKind.Net, hosts); + hosts[0] = "evil.example"; + hosts[1] = "attacker.example"; + }); + + Assert.Equal(["run", "--allow-net=localhost,api.internal", "main.ts"], args); + } + + [Fact] + public async Task WithDenoAllowAll_False_DropsBlanketGrant() + { + // Least-privilege: explicitly opting out of -A must not emit any allow-all flag; only `run