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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions extension/src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ export type Capability =
| 'project' // Support for running C# projects
| 'ms-dotnettools.csharp' // Older AppHost versions used this extension identifier instead of project
| 'python' // Support for running Python projects
| 'ms-python.python'; // Older AppHost versions used this extension identifier instead of python
| 'ms-python.python' // Older AppHost versions used this extension identifier instead of python
| 'node' // Support for running Node.js projects
| 'browser'; // Support for browser debugging (built-in to VS Code via js-debug)

export type Capabilities = Capability[];

Expand All @@ -33,8 +35,14 @@ export function isPythonInstalled() {
return isExtensionInstalled("ms-python.python");
}

export function isNodeInstalled() {
// Node.js debugging uses VS Code's built-in js-debug, no extension needed
return true;
}

export function getSupportedCapabilities(): Capabilities {
const capabilities: Capabilities = ['prompting', 'baseline.v1', 'secret-prompts.v1', 'file-pickers.v1', 'build-dotnet-using-cli'];
// Node.js and browser debugging are built into VS Code via ms-vscode.js-debug, so always available
const capabilities: Capabilities = ['prompting', 'baseline.v1', 'secret-prompts.v1', 'file-pickers.v1', 'build-dotnet-using-cli', 'node', 'browser'];

if (isCsDevKitInstalled()) {
capabilities.push("devkit");
Expand Down
21 changes: 21 additions & 0 deletions extension/src/dcp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,27 @@ export function isPythonLaunchConfiguration(obj: any): obj is PythonLaunchConfig
return obj && obj.type === 'python';
}

export interface NodeLaunchConfiguration extends ExecutableLaunchConfiguration {
type: "node";
script_path?: string;
runtime_executable?: string;
}

export function isNodeLaunchConfiguration(obj: any): obj is NodeLaunchConfiguration {
return obj && obj.type === 'node';
}

export interface BrowserLaunchConfiguration extends ExecutableLaunchConfiguration {
type: "browser";
url?: string;
web_root?: string;
browser?: string;
}

export function isBrowserLaunchConfiguration(obj: any): obj is BrowserLaunchConfiguration {
return obj && obj.type === 'browser';
}

export interface EnvVar {
name: string;
value: string;
Expand Down
5 changes: 5 additions & 0 deletions extension/src/debugger/debuggerExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { extensionLogOutputChannel } from "../utils/logging";
import { projectDebuggerExtension } from "./languages/dotnet";
import { isCsharpInstalled, isPythonInstalled } from "../capabilities";
import { pythonDebuggerExtension } from "./languages/python";
import { nodeDebuggerExtension } from "./languages/node";
import { browserDebuggerExtension } from "./languages/browser";
import { isDirectory } from "../utils/io";

// Represents a resource-specific debugger extension for when the default session configuration is not sufficient to launch the resource.
Expand Down Expand Up @@ -72,6 +74,9 @@ export function getResourceDebuggerExtensions(): ResourceDebuggerExtension[] {
extensions.push(pythonDebuggerExtension);
}

extensions.push(nodeDebuggerExtension);
extensions.push(browserDebuggerExtension);

return extensions;
}

38 changes: 38 additions & 0 deletions extension/src/debugger/languages/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, isBrowserLaunchConfiguration } from "../../dcp/types";
import { invalidLaunchConfiguration } from "../../loc/strings";
import { extensionLogOutputChannel } from "../../utils/logging";
import { ResourceDebuggerExtension } from "../debuggerExtensions";

export const browserDebuggerExtension: ResourceDebuggerExtension = {
resourceType: 'browser',
debugAdapter: 'pwa-msedge',
extensionId: null, // built-in to VS Code via js-debug
getDisplayName: (launchConfiguration: ExecutableLaunchConfiguration) => {
if (isBrowserLaunchConfiguration(launchConfiguration) && launchConfiguration.url) {
return `Browser: ${launchConfiguration.url}`;
}
return 'Browser';
},
getSupportedFileTypes: () => [],
getProjectFile: () => '',
createDebugSessionConfigurationCallback: async (launchConfig, _args, _env, _launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise<void> => {
if (!isBrowserLaunchConfiguration(launchConfig)) {
extensionLogOutputChannel.info(`The resource type was not browser for ${JSON.stringify(launchConfig)}`);
throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig)));
}

debugConfiguration.type = launchConfig.browser || 'msedge';
debugConfiguration.request = 'launch';
debugConfiguration.url = launchConfig.url;
debugConfiguration.webRoot = launchConfig.web_root;
debugConfiguration.sourceMaps = true;
debugConfiguration.resolveSourceMapLocations = ['**', '!**/node_modules/**'];
// Use an auto-managed temp user data directory so multiple browser debuggers
// can run concurrently without conflicting
debugConfiguration.userDataDir = true;

// Remove program/args/cwd since browser debugging doesn't use them
delete debugConfiguration.program;
delete debugConfiguration.args;
}
};
44 changes: 44 additions & 0 deletions extension/src/debugger/languages/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, isNodeLaunchConfiguration } from "../../dcp/types";
import { invalidLaunchConfiguration } from "../../loc/strings";
import { extensionLogOutputChannel } from "../../utils/logging";
import { ResourceDebuggerExtension } from "../debuggerExtensions";
import * as vscode from 'vscode';

function getProjectFile(launchConfig: ExecutableLaunchConfiguration): string {
if (isNodeLaunchConfiguration(launchConfig)) {
return launchConfig.script_path || '';
}

throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig)));
}
Comment on lines +7 to +13
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getProjectFile() throws when script_path is missing/empty. The apphost currently sends script_path: "" for package-manager based JavaScript resources, so debugging those resources will fail before a session starts. Consider falling back to a directory path (e.g., workspace folder / resource working directory) when script_path is empty, or adjust the apphost launch configuration to always send a usable path.

Copilot uses AI. Check for mistakes.

export const nodeDebuggerExtension: ResourceDebuggerExtension = {
resourceType: 'node',
debugAdapter: 'node',
extensionId: null,
getDisplayName: (launchConfiguration: ExecutableLaunchConfiguration) => `Node.js: ${vscode.workspace.asRelativePath(getProjectFile(launchConfiguration))}`,
getSupportedFileTypes: () => ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts'],
getProjectFile: (launchConfig) => getProjectFile(launchConfig),
createDebugSessionConfigurationCallback: async (launchConfig, args, env, launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise<void> => {
if (!isNodeLaunchConfiguration(launchConfig)) {
extensionLogOutputChannel.info(`The resource type was not node for ${JSON.stringify(launchConfig)}`);
throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig)));
}

debugConfiguration.type = 'node';

if (launchConfig.runtime_executable) {
debugConfiguration.runtimeExecutable = launchConfig.runtime_executable;
}

// For package manager script execution (e.g., npm run dev), use args directly as runtimeArgs.
// The args from DCP already contain the full command (e.g., ["run", "dev", "--port", "5173"]).
if (launchConfig.runtime_executable && launchConfig.runtime_executable !== 'node') {
debugConfiguration.runtimeArgs = args ?? [];
delete debugConfiguration.args;
delete debugConfiguration.program;
}

debugConfiguration.resolveSourceMapLocations = ['**', '!**/node_modules/**'];
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

<ItemGroup>
<Compile Include="$(SharedDir)PathNormalizer.cs" Link="Utils\PathNormalizer.cs" />
<Compile Include="$(RepoRoot)src\Aspire.Hosting\Dcp\Model\ExecutableLaunchConfiguration.cs" />
</ItemGroup>
Comment on lines 10 to 13
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The <Compile Include="$(RepoRoot)src\Aspire.Hosting\Dcp\Model\ExecutableLaunchConfiguration.cs" /> path uses backslashes and isn’t linked into the project. For cross-platform builds it’s safer to use forward slashes and/or Link=... (or move this file under $(SharedDir)), otherwise path handling can be brittle on non-Windows agents.

Copilot uses AI. Check for mistakes.

<ItemGroup>
Expand Down
11 changes: 11 additions & 0 deletions src/Aspire.Hosting.JavaScript/BrowserDebuggerResource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// 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 BrowserDebuggerResource(string name, string browser, string workingDirectory)
: ExecutableResource(name, browser, workingDirectory)
{
}
19 changes: 19 additions & 0 deletions src/Aspire.Hosting.JavaScript/BrowserLaunchConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Serialization;
using Aspire.Hosting.Dcp.Model;

namespace Aspire.Hosting.JavaScript;

internal sealed class BrowserLaunchConfiguration() : ExecutableLaunchConfiguration("browser")
{
[JsonPropertyName("url")]
public string Url { get; set; } = string.Empty;

[JsonPropertyName("web_root")]
public string WebRoot { get; set; } = string.Empty;

[JsonPropertyName("browser")]
public string Browser { get; set; } = "msedge";
}
133 changes: 133 additions & 0 deletions src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
#pragma warning disable ASPIREDOCKERFILEBUILDER001
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIRECERTIFICATES001
#pragma warning disable ASPIREEXTENSION001

using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.ApplicationModel.Docker;
using Aspire.Hosting.JavaScript;
Expand All @@ -26,6 +29,9 @@ public static class JavaScriptHostingExtensions
{
private const string DefaultNodeVersion = "22";

// This must match the value in Aspire.Cli KnownCapabilities.Browser
private const string BrowserCapability = "browser";

// This is the order of config files that Vite will look for by default
// See https://github.com/vitejs/vite/blob/main/packages/vite/src/node/constants.ts#L97
private static readonly string[] s_defaultConfigFiles = ["vite.config.js", "vite.config.mjs", "vite.config.ts", "vite.config.cjs", "vite.config.mts", "vite.config.cts"];
Expand Down Expand Up @@ -258,6 +264,8 @@ public static IResourceBuilder<NodeAppResource> AddNodeApp(this IDistributedAppl
resourceBuilder.WithNpm();
}

resourceBuilder.WithVSCodeDebugging(scriptPath);

if (builder.ExecutionContext.IsRunMode)
{
builder.Eventing.Subscribe<BeforeStartEvent>((_, _) =>
Expand Down Expand Up @@ -460,6 +468,8 @@ private static IResourceBuilder<TResource> CreateDefaultJavaScriptAppBuilder<TRe
.WithBuildScript("build")
.WithRunScript(runScriptName);

resourceBuilder.WithVSCodeDebugging();

// ensure the package manager command is set before starting the resource
if (builder.ExecutionContext.IsRunMode)
{
Expand Down Expand Up @@ -935,6 +945,129 @@ public static IResourceBuilder<TResource> WithRunScript<TResource>(this IResourc
return resource.WithAnnotation(new JavaScriptRunScriptAnnotation(scriptName, args));
}

[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
internal static IResourceBuilder<T> WithVSCodeDebugging<T>(this IResourceBuilder<T> builder, string scriptPath)
where T : NodeAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(scriptPath);

// Check if a run script annotation is present - if so, use package manager instead of direct node
var hasRunScript = builder.Resource.TryGetLastAnnotation<JavaScriptRunScriptAnnotation>(out _);
var hasPackageManager = builder.Resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out var pmAnnotation);

var runtimeExecutable = hasRunScript && hasPackageManager ? pmAnnotation!.ExecutableName : "node";

return builder.WithDebugSupport(
mode => new NodeLaunchConfiguration
{
ScriptPath = scriptPath,
Mode = mode,
RuntimeExecutable = runtimeExecutable
},
"node");
}
Comment on lines 948 to 969
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WithVSCodeDebugging(builder, scriptPath) forwards the provided scriptPath directly into the launch configuration. scriptPath is typically relative (e.g., "app.js"), but the extension uses it as the program and derives cwd from path.dirname(script_path), which will resolve incorrectly. Consider emitting an absolute path (e.g., Path.GetFullPath(scriptPath, builder.Resource.WorkingDirectory)) and using the argsCallback hook to remove the script path from the resource args in IDE mode (since VS Code’s node debug config uses program separately).

Copilot uses AI. Check for mistakes.

[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
internal static IResourceBuilder<T> WithVSCodeDebugging<T>(this IResourceBuilder<T> builder)
where T : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(builder);

// Get package manager info for runtime executable
var packageManager = "npm";

if (builder.Resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out var pmAnnotation))
{
packageManager = pmAnnotation.ExecutableName;
}

return builder.WithDebugSupport(
mode => new NodeLaunchConfiguration
{
ScriptPath = string.Empty,
Mode = mode,
RuntimeExecutable = packageManager
},
"node");
Comment on lines 971 to +992
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WithVSCodeDebugging() for JavaScriptAppResource sets ScriptPath to an empty string. The extension’s node debugger implementation currently requires script_path to compute program/cwd/displayName, so this will fail for AddJavaScriptApp/AddViteApp scenarios. Consider setting script_path to something non-empty (e.g., the app working directory or a specific entry file) for package-manager based apps.

Copilot uses AI. Check for mistakes.
}

[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
internal static IResourceBuilder<T> WithBrowserDebugger<T>(
this IResourceBuilder<T> builder,
string browser = "msedge")
where T : JavaScriptAppResource
{
ArgumentNullException.ThrowIfNull(builder);

// Validate that the extension supports browser debugging if we're running in an extension context
ValidateBrowserCapability(builder);

var parentResource = builder.Resource;
var debuggerResourceName = $"{parentResource.Name}-browser";

// Find the parent's HTTP/HTTPS endpoint
EndpointAnnotation? endpointAnnotation = null;
if (parentResource.TryGetAnnotationsOfType<EndpointAnnotation>(out var endpoints))
{
endpointAnnotation = endpoints.FirstOrDefault(e => e.UriScheme == "https")
?? endpoints.FirstOrDefault(e => e.UriScheme == "http");
}

if (endpointAnnotation is null)
{
throw new InvalidOperationException(
$"Resource '{parentResource.Name}' does not have an HTTP or HTTPS endpoint. Browser debugging requires an endpoint to navigate to.");
}

var endpointReference = parentResource.GetEndpoint(endpointAnnotation.Name);

var debuggerResource = new BrowserDebuggerResource(debuggerResourceName, browser, parentResource.WorkingDirectory);

builder.ApplicationBuilder.AddResource(debuggerResource)
.WithParentRelationship(parentResource)
.WaitFor(builder)
.ExcludeFromManifest()
.WithDebugSupport(
mode => new BrowserLaunchConfiguration
{
Mode = mode,
Url = endpointReference.Url,
WebRoot = parentResource.WorkingDirectory,
Browser = browser
},
BrowserCapability);

return builder;
}

private static void ValidateBrowserCapability<T>(IResourceBuilder<T> builder) where T : IResource
{
var configuration = builder.ApplicationBuilder.Configuration;

try
{
if (configuration["DEBUG_SESSION_INFO"] is { } debugSessionInfoJson
&& JsonSerializer.Deserialize<DebugSessionCapabilities>(debugSessionInfoJson) is { } info
&& info.SupportedLaunchConfigurations is not null
&& !info.SupportedLaunchConfigurations.Contains(BrowserCapability))
{
throw new InvalidOperationException(
"This version of the Aspire extension does not support browser debugging. Please update the Aspire extension to use browser debugging support with WithBrowserDebugger().");
}
}
catch (JsonException)
{
// If we can't parse the debug session info, skip validation
}
}

private sealed class DebugSessionCapabilities
{
[JsonPropertyName("supported_launch_configurations")]
public string[]? SupportedLaunchConfigurations { get; set; }
}

private static void AddInstaller<TResource>(IResourceBuilder<TResource> resource, bool install) where TResource : JavaScriptAppResource
{
// Only install packages if in run mode
Expand Down
16 changes: 16 additions & 0 deletions src/Aspire.Hosting.JavaScript/NodeLaunchConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Serialization;
using Aspire.Hosting.Dcp.Model;

namespace Aspire.Hosting.JavaScript;

internal sealed class NodeLaunchConfiguration() : ExecutableLaunchConfiguration("node")
{
[JsonPropertyName("script_path")]
public string ScriptPath { get; set; } = string.Empty;

[JsonPropertyName("runtime_executable")]
public string RuntimeExecutable { get; set; } = string.Empty;
}
Loading
Loading