diff --git a/extension/src/capabilities.ts b/extension/src/capabilities.ts index f1441c1e16a..de1be228e69 100644 --- a/extension/src/capabilities.ts +++ b/extension/src/capabilities.ts @@ -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[]; @@ -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"); diff --git a/extension/src/dcp/types.ts b/extension/src/dcp/types.ts index 47c9f804a31..f6f175cae29 100644 --- a/extension/src/dcp/types.ts +++ b/extension/src/dcp/types.ts @@ -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; diff --git a/extension/src/debugger/debuggerExtensions.ts b/extension/src/debugger/debuggerExtensions.ts index c314c44fcb1..6559d012f6f 100644 --- a/extension/src/debugger/debuggerExtensions.ts +++ b/extension/src/debugger/debuggerExtensions.ts @@ -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. @@ -72,6 +74,9 @@ export function getResourceDebuggerExtensions(): ResourceDebuggerExtension[] { extensions.push(pythonDebuggerExtension); } + extensions.push(nodeDebuggerExtension); + extensions.push(browserDebuggerExtension); + return extensions; } diff --git a/extension/src/debugger/languages/browser.ts b/extension/src/debugger/languages/browser.ts new file mode 100644 index 00000000000..2391b04d967 --- /dev/null +++ b/extension/src/debugger/languages/browser.ts @@ -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 => { + 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; + } +}; diff --git a/extension/src/debugger/languages/node.ts b/extension/src/debugger/languages/node.ts new file mode 100644 index 00000000000..6c2e3efdaac --- /dev/null +++ b/extension/src/debugger/languages/node.ts @@ -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 => { + 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/**']; + } +}; diff --git a/src/Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.csproj b/src/Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.csproj index d49d7d13609..809bcf2658e 100644 --- a/src/Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.csproj +++ b/src/Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Aspire.Hosting.JavaScript/BrowserDebuggerResource.cs b/src/Aspire.Hosting.JavaScript/BrowserDebuggerResource.cs new file mode 100644 index 00000000000..63b8ddd14d2 --- /dev/null +++ b/src/Aspire.Hosting.JavaScript/BrowserDebuggerResource.cs @@ -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) +{ +} diff --git a/src/Aspire.Hosting.JavaScript/BrowserLaunchConfiguration.cs b/src/Aspire.Hosting.JavaScript/BrowserLaunchConfiguration.cs new file mode 100644 index 00000000000..8e2a82c545e --- /dev/null +++ b/src/Aspire.Hosting.JavaScript/BrowserLaunchConfiguration.cs @@ -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"; +} diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index 3d1eb4b7462..52d262cf67c 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -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 AddNodeApp(this IDistributedAppl resourceBuilder.WithNpm(); } + resourceBuilder.WithVSCodeDebugging(scriptPath); + if (builder.ExecutionContext.IsRunMode) { builder.Eventing.Subscribe((_, _) => @@ -460,6 +468,8 @@ private static IResourceBuilder CreateDefaultJavaScriptAppBuilder WithRunScript(this IResourc return resource.WithAnnotation(new JavaScriptRunScriptAnnotation(scriptName, args)); } + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder 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(out _); + var hasPackageManager = builder.Resource.TryGetLastAnnotation(out var pmAnnotation); + + var runtimeExecutable = hasRunScript && hasPackageManager ? pmAnnotation!.ExecutableName : "node"; + + return builder.WithDebugSupport( + mode => new NodeLaunchConfiguration + { + ScriptPath = scriptPath, + Mode = mode, + RuntimeExecutable = runtimeExecutable + }, + "node"); + } + + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder builder) + where T : JavaScriptAppResource + { + ArgumentNullException.ThrowIfNull(builder); + + // Get package manager info for runtime executable + var packageManager = "npm"; + + if (builder.Resource.TryGetLastAnnotation(out var pmAnnotation)) + { + packageManager = pmAnnotation.ExecutableName; + } + + return builder.WithDebugSupport( + mode => new NodeLaunchConfiguration + { + ScriptPath = string.Empty, + Mode = mode, + RuntimeExecutable = packageManager + }, + "node"); + } + + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + internal static IResourceBuilder WithBrowserDebugger( + this IResourceBuilder 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(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(IResourceBuilder builder) where T : IResource + { + var configuration = builder.ApplicationBuilder.Configuration; + + try + { + if (configuration["DEBUG_SESSION_INFO"] is { } debugSessionInfoJson + && JsonSerializer.Deserialize(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(IResourceBuilder resource, bool install) where TResource : JavaScriptAppResource { // Only install packages if in run mode diff --git a/src/Aspire.Hosting.JavaScript/NodeLaunchConfiguration.cs b/src/Aspire.Hosting.JavaScript/NodeLaunchConfiguration.cs new file mode 100644 index 00000000000..6fb1a1d0d03 --- /dev/null +++ b/src/Aspire.Hosting.JavaScript/NodeLaunchConfiguration.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using 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; +} diff --git a/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppTests.cs index 328cb7b7b06..5b7b31f4d6f 100644 --- a/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppTests.cs +++ b/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppTests.cs @@ -243,7 +243,7 @@ public void AddViteApp_WithViteConfigPath_AppliesConfigArgument() var nodeResource = Assert.Single(appModel.Resources.OfType()); // Get the command line args annotation to inspect the args callback - var commandLineArgsAnnotation = nodeResource.Annotations.OfType().Single(); + var commandLineArgsAnnotation = nodeResource.Annotations.OfType().First(); var args = new List(); var context = new CommandLineArgsCallbackContext(args, nodeResource); commandLineArgsAnnotation.Callback(context); @@ -268,7 +268,7 @@ public void AddViteApp_WithoutViteConfigPath_DoesNotApplyConfigArgument() var nodeResource = Assert.Single(appModel.Resources.OfType()); // Get the command line args annotation to inspect the args callback - var commandLineArgsAnnotation = nodeResource.Annotations.OfType().Single(); + var commandLineArgsAnnotation = nodeResource.Annotations.OfType().First(); var args = new List(); var context = new CommandLineArgsCallbackContext(args, nodeResource); commandLineArgsAnnotation.Callback(context); diff --git a/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppWithPnpmTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppWithPnpmTests.cs index e0a6f4ce108..609595b52ea 100644 --- a/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppWithPnpmTests.cs +++ b/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppWithPnpmTests.cs @@ -28,7 +28,7 @@ public void AddViteApp_WithPnpm_DoesNotIncludeSeparator() Assert.Equal("run", packageManager.ScriptCommand); // Get the command line args annotation to inspect the args callback - var commandLineArgsAnnotation = nodeResource.Annotations.OfType().Single(); + var commandLineArgsAnnotation = nodeResource.Annotations.OfType().First(); var args = new List(); var context = new CommandLineArgsCallbackContext(args, nodeResource); commandLineArgsAnnotation.Callback(context); @@ -62,7 +62,7 @@ public void AddViteApp_WithBun_DoesNotIncludeSeparator() Assert.Equal("run", packageManager.ScriptCommand); // Get the command line args annotation to inspect the args callback - var commandLineArgsAnnotation = nodeResource.Annotations.OfType().Single(); + var commandLineArgsAnnotation = nodeResource.Annotations.OfType().First(); var args = new List(); var context = new CommandLineArgsCallbackContext(args, nodeResource); commandLineArgsAnnotation.Callback(context); @@ -90,7 +90,7 @@ public void AddViteApp_WithNpm_IncludesSeparator() Assert.Equal("npm", nodeResource.Command); // Get the command line args annotation to inspect the args callback - var commandLineArgsAnnotation = nodeResource.Annotations.OfType().Single(); + var commandLineArgsAnnotation = nodeResource.Annotations.OfType().First(); var args = new List(); var context = new CommandLineArgsCallbackContext(args, nodeResource); commandLineArgsAnnotation.Callback(context); @@ -123,7 +123,7 @@ public void AddViteApp_WithYarn_IncludesSeparator() Assert.Equal("run", packageManager.ScriptCommand); // Get the command line args annotation to inspect the args callback - var commandLineArgsAnnotation = nodeResource.Annotations.OfType().Single(); + var commandLineArgsAnnotation = nodeResource.Annotations.OfType().First(); var args = new List(); var context = new CommandLineArgsCallbackContext(args, nodeResource); commandLineArgsAnnotation.Callback(context);