-
Notifications
You must be signed in to change notification settings - Fork 850
Minimal JavaScript debugging support modeled after Python implementation #15067
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } | ||
| }; |
| 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))); | ||
| } | ||
|
|
||
| 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 |
|---|---|---|
|
|
@@ -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
|
||
|
|
||
| <ItemGroup> | ||
|
|
||
| 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) | ||
| { | ||
| } |
| 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"; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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"]; | ||
|
|
@@ -258,6 +264,8 @@ public static IResourceBuilder<NodeAppResource> AddNodeApp(this IDistributedAppl | |
| resourceBuilder.WithNpm(); | ||
| } | ||
|
|
||
| resourceBuilder.WithVSCodeDebugging(scriptPath); | ||
|
|
||
| if (builder.ExecutionContext.IsRunMode) | ||
| { | ||
| builder.Eventing.Subscribe<BeforeStartEvent>((_, _) => | ||
|
|
@@ -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) | ||
| { | ||
|
|
@@ -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
|
||
|
|
||
| [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
|
||
| } | ||
|
|
||
| [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 | ||
|
|
||
| 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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getProjectFile()throws whenscript_pathis missing/empty. The apphost currently sendsscript_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) whenscript_pathis empty, or adjust the apphost launch configuration to always send a usable path.