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