diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index d3134c27d16..aa4c369568e 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import { EventEmitter } from "vscode"; import * as fs from "fs"; import { createDebugAdapterTracker, AppHostOutputHandler, AppHostRestartHandler } from "./adapterTracker"; -import { AspireResourceExtendedDebugConfiguration, AspireResourceDebugSession, EnvVar, AspireExtendedDebugConfiguration, NodeLaunchConfiguration, ProjectLaunchConfiguration, StartAppHostOptions } from "../dcp/types"; +import { AspireResourceExtendedDebugConfiguration, AspireResourceDebugSession, EnvVar, AspireExtendedDebugConfiguration, NodeLaunchConfiguration, ProjectLaunchConfiguration, SessionTerminatedNotification, StartAppHostOptions } from "../dcp/types"; import { extensionLogOutputChannel } from "../utils/logging"; import AspireDcpServer, { generateDcpIdPrefix } from "../dcp/AspireDcpServer"; import { spawnCliProcess } from "./languages/cli"; @@ -44,6 +44,9 @@ export class AspireDebugSession implements vscode.DebugAdapter { public readonly debugSessionId: string; public configuration: AspireExtendedDebugConfiguration; + /** The underlying VS Code debug session. Used as the parent when starting child debug sessions. */ + public get session(): vscode.DebugSession { return this._session; } + constructor(session: vscode.DebugSession, rpcServer: AspireRpcServer, dcpServer: AspireDcpServer, terminalProvider: AspireTerminalProvider, removeAspireDebugSession: (session: AspireDebugSession) => void) { this._session = session; this._rpcServer = rpcServer; @@ -263,6 +266,21 @@ export class AspireDebugSession implements vscode.DebugAdapter { this._disposables.push(createDebugAdapterTracker(this._dcpServer, debugAdapter, onAppHostRestartRequested, onAppHostOutput)); } + /** + * Sends a sessionTerminated notification to DCP for the given run session. + * Used by resource debugger extensions (e.g., browser) to notify DCP when + * a debug session ends outside of the normal adapter tracker flow. + */ + sendSessionTerminated(sessionId: string, dcpId: string, exitCode: number = 0): void { + const notification: SessionTerminatedNotification = { + notification_type: 'sessionTerminated', + session_id: sessionId, + dcp_id: dcpId, + exit_code: exitCode + }; + this._dcpServer.sendNotification(notification); + } + private static readonly _nodeAppHostExtensions = ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts']; private static readonly _csharpAppHostExtensions = ['.cs', '.csproj']; diff --git a/extension/src/debugger/languages/browser.ts b/extension/src/debugger/languages/browser.ts index 5719a1cd521..7733b6e7bdf 100644 --- a/extension/src/debugger/languages/browser.ts +++ b/extension/src/debugger/languages/browser.ts @@ -1,11 +1,15 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as os from 'os'; import { AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, isBrowserLaunchConfiguration } from "../../dcp/types"; import { browserDisplayName, browserLabel, invalidLaunchConfiguration } from "../../loc/strings"; import { extensionLogOutputChannel } from "../../utils/logging"; import { ResourceDebuggerExtension } from "../debuggerExtensions"; +import { tryStartWasmDebugging } from "./wasmDebug"; export const browserDebuggerExtension: ResourceDebuggerExtension = { resourceType: 'browser', - debugAdapter: 'pwa-msedge', + debugAdapter: 'msedge', extensionId: null, // built-in to VS Code via js-debug getDisplayName: (launchConfiguration: ExecutableLaunchConfiguration) => { if (isBrowserLaunchConfiguration(launchConfiguration) && launchConfiguration.url) { @@ -15,27 +19,85 @@ export const browserDebuggerExtension: ResourceDebuggerExtension = { }, getSupportedFileTypes: () => [], getProjectFile: () => '', - createDebugSessionConfigurationCallback: async (launchConfig, _args, _env, _launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise => { + 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))); } - // Map browser name to VS Code js-debug adapter type (pwa- prefix required) + // Map browser name to VS Code js-debug adapter type. + // js-debug registers both 'msedge'/'chrome' and 'pwa-msedge'/'pwa-chrome'. const browser = launchConfig.browser || 'msedge'; - debugConfiguration.type = `pwa-${browser}`; + debugConfiguration.type = browser; debugConfiguration.request = 'launch'; debugConfiguration.url = launchConfig.url; - debugConfiguration.webRoot = launchConfig.web_root; + + // webRoot must be a directory for source map resolution. + // When web_root is a .csproj path (Blazor WASM), use the containing directory. + const webRoot = launchConfig.web_root?.endsWith('.csproj') + ? path.dirname(launchConfig.web_root) + : launchConfig.web_root; + debugConfiguration.webRoot = webRoot; + 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; + // Use a stable user data directory dedicated to Aspire debugging. + // This avoids the managed-profile sign-in prompt that appears with a fresh + // temp dir (userDataDir: true) on corp machines, and avoids conflicts with + // the user's existing browser profile (userDataDir: false). + debugConfiguration.userDataDir = path.join(os.tmpdir(), 'aspire-browser-debug'); + + // Suppress Edge/Chrome first-run wizards and profile selection prompts + // that appear on managed machines with enterprise policies. + debugConfiguration.runtimeArgs = [ + '--no-first-run', + '--no-default-browser-check', + '--hide-crash-restore-bubble', + '--disable-features=EdgeProfileOnStartup,msEdgeFirstRunExperience,EdgeBackgroundMode', + '--disable-background-mode', + ]; // Remove program/args/cwd since browser debugging doesn't use them delete debugConfiguration.program; delete debugConfiguration.args; delete debugConfiguration.cwd; + + // If web_root points to a .csproj, this is a Blazor WASM project — + // wire up managed debugging via the C# extension's VSWebAssemblyBridge. + if (launchConfig.web_root?.endsWith('.csproj') && launchConfig.url) { + extensionLogOutputChannel.info(`[WASM] Detected Blazor WASM project: ${launchConfig.web_root}`); + const wasmStarted = await tryStartWasmDebugging(launchConfig.url, launchConfig.web_root, debugConfiguration, launchOptions.debugSession); + extensionLogOutputChannel.info(`[WASM] tryStartWasmDebugging result: ${wasmStarted}`); + if (wasmStarted) { + // The browser session must have debugging enabled so js-debug connects + // to the DevTools protocol through the bridge's port/inspectUri. + // Without this, noDebug:true causes js-debug to skip attaching entirely. + debugConfiguration.noDebug = false; + } + } + extensionLogOutputChannel.info(`[Browser] Final debug config: type=${debugConfiguration.type}, url=${debugConfiguration.url}, webRoot=${debugConfiguration.webRoot}, noDebug=${debugConfiguration.noDebug}`); + + // Listen for the browser debug session to terminate (e.g., user closes the browser window). + // When it does, notify DCP so the resource transitions to a terminal state and + // the dashboard UI can reset. + // We match by session name only because js-debug child sessions do not carry + // custom configuration properties (runId) from the parent launch config. + const runId = debugConfiguration.runId; + const debugSessionId = debugConfiguration.debugSessionId; + const aspireSession = launchOptions.debugSession; + const browserSessionName = debugConfiguration.name; + + extensionLogOutputChannel.info(`[Browser] Registering terminate listener for session name="${browserSessionName}", runId=${runId}, debugSessionId=${debugSessionId}`); + + if (runId && debugSessionId) { + const disposable = vscode.debug.onDidTerminateDebugSession((session) => { + extensionLogOutputChannel.info(`[Browser] onDidTerminateDebugSession fired: name="${session.name}", configRunId=${session.configuration?.runId}, expected="${browserSessionName}"`); + if (session.name === browserSessionName) { + disposable.dispose(); + extensionLogOutputChannel.info(`[Browser] Browser debug session terminated — notifying DCP (runId: ${runId}, debugSessionId: ${debugSessionId})`); + aspireSession.sendSessionTerminated(runId, debugSessionId, 0); + } + }); + } } }; diff --git a/extension/src/debugger/languages/wasmDebug.ts b/extension/src/debugger/languages/wasmDebug.ts new file mode 100644 index 00000000000..3eac3c31986 --- /dev/null +++ b/extension/src/debugger/languages/wasmDebug.ts @@ -0,0 +1,133 @@ +import * as vscode from 'vscode'; +import { AspireResourceExtendedDebugConfiguration } from '../../dcp/types'; +import { extensionLogOutputChannel } from '../../utils/logging'; +import { AspireDebugSession } from '../AspireDebugSession'; + +const CSHARP_EXTENSION_ID = 'ms-dotnettools.csharp'; + +interface CSharpExtensionExports { + tryToUseVSDbgForMono?: (url: string, projectPath: string) => Promise<[string, number, number]>; +} + +/** + * Attempts to start a managed WebAssembly debug session via the C# extension's + * VSWebAssemblyBridge. If successful, modifies the browser debug configuration + * to connect through the bridge and starts a companion monovsdbg_wasm session + * for .NET IL debugging. + * + * The monovsdbg_wasm session is started as a child of the parent Aspire debug session + * so it is properly tracked and terminated when the Aspire session ends. + * + * @param url The application URL to debug + * @param projectPath Path to the Blazor WASM client .csproj + * @param debugConfiguration The browser debug configuration to modify + * @param parentDebugSession The parent Aspire debug session (used as parent for child sessions) + * @returns true if WASM debugging was successfully wired up, false if falling back to plain browser + */ +export async function tryStartWasmDebugging( + url: string, + projectPath: string, + debugConfiguration: AspireResourceExtendedDebugConfiguration, + parentDebugSession: AspireDebugSession +): Promise { + const csharpExt = vscode.extensions.getExtension(CSHARP_EXTENSION_ID); + if (!csharpExt) { + extensionLogOutputChannel.warn('C# extension not installed — skipping WASM debugging'); + return false; + } + + if (!csharpExt.isActive) { + await csharpExt.activate(); + } + + if (!csharpExt.exports?.tryToUseVSDbgForMono) { + extensionLogOutputChannel.warn('C# extension does not export tryToUseVSDbgForMono — skipping WASM debugging'); + return false; + } + + let inspectUri: string; + let portICorDebug: number; + let portBrowserDebug: number; + + try { + [inspectUri, portICorDebug, portBrowserDebug] = + await csharpExt.exports.tryToUseVSDbgForMono(url, projectPath); + } catch (e) { + extensionLogOutputChannel.warn(`tryToUseVSDbgForMono threw: ${e}`); + return false; + } + + if (inspectUri === '') { + extensionLogOutputChannel.info('tryToUseVSDbgForMono returned empty inspectUri — falling back to plain browser'); + return false; + } + + extensionLogOutputChannel.info( + `WASM debug bridge ready — inspectUri: ${inspectUri}, iCorDebug port: ${portICorDebug}, browser port: ${portBrowserDebug}` + ); + + // Start the managed WASM debug session (monovsdbg_wasm). + // This connects to the bridge's iCorDebug port and provides .NET IL debugging + // (breakpoints, stepping, locals, etc.) for code running in the browser. + const wasmManagedConfig: vscode.DebugConfiguration = { + name: `${debugConfiguration.name} Wasm Managed`, + type: 'monovsdbg_wasm', + request: 'launch', + monoDebuggerOptions: { + ip: '127.0.0.1', + port: portICorDebug, + platform: 'browser', + isServer: true, + }, + // Cascade termination: when the browser session ends, terminate the managed debugger too + cascadeTerminateToConfigurations: [debugConfiguration.name], + }; + + extensionLogOutputChannel.info(`[WASM] Attempting to start monovsdbg_wasm session with config: ${JSON.stringify(wasmManagedConfig)}`); + + // Start the monovsdbg_wasm session as a child of the Aspire debug session. + // This ensures it's tracked and terminated when the Aspire session ends. + const debugSessionStarted = await vscode.debug.startDebugging( + vscode.workspace.workspaceFolders?.[0], wasmManagedConfig, { parentSession: parentDebugSession.session }); + if (!debugSessionStarted) { + extensionLogOutputChannel.warn('Failed to start monovsdbg_wasm session — falling back to plain browser'); + return false; + } + + // Use the inspectUri returned by the bridge directly. + // The bridge acts as the debug proxy (replaces /_framework/debug/ws-proxy). + // The inspectUri contains js-debug placeholders like {browserInspectUriPath} + // which js-debug resolves at runtime using the browser's DevTools WebSocket path. + debugConfiguration.inspectUri = inspectUri; + + // Tell js-debug to launch the browser with remote debugging on the port + // the bridge expects. The bridge connects to the browser on this port to + // proxy DevTools protocol messages. + (debugConfiguration as any).port = portBrowserDebug; + + // The WASM runtime checks for a debugger connection at startup. Since the browser + // launches before the debugger attaches, the runtime initializes with debugging + // disabled. We must reload the page after the browser debug session connects so + // the runtime re-initializes and calls mono_wasm_enable_debugging(). + const browserSessionName = debugConfiguration.name; + const disposable = vscode.debug.onDidStartDebugSession(async (session) => { + if (session.name === browserSessionName && session.type === debugConfiguration.type) { + disposable.dispose(); + extensionLogOutputChannel.info(`[WASM] Browser session started — reloading page to activate WASM debugging`); + // Give the browser a moment to fully connect before reloading + await new Promise(resolve => setTimeout(resolve, 1000)); + try { + // Use the DAP custom request to reload the page via CDP + await session.customRequest('evaluate', { + expression: 'location.reload()', + context: 'repl', + }); + extensionLogOutputChannel.info(`[WASM] Page reload triggered`); + } catch (e) { + extensionLogOutputChannel.warn(`[WASM] Failed to reload page: ${e}`); + } + } + }); + + return true; +} diff --git a/playground/BlazorHosted/BlazorHosted.Client/BlazorHosted.Client.csproj b/playground/BlazorHosted/BlazorHosted.Client/BlazorHosted.Client.csproj index 95d8cf49e76..57f1df59f59 100644 --- a/playground/BlazorHosted/BlazorHosted.Client/BlazorHosted.Client.csproj +++ b/playground/BlazorHosted/BlazorHosted.Client/BlazorHosted.Client.csproj @@ -8,6 +8,17 @@ true Default true + + true + + 2 + + portable + + false true true diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/Layout/MainLayout.razor b/playground/BlazorHosted/BlazorHosted.Client/Layout/MainLayout.razor similarity index 100% rename from playground/BlazorHosted/BlazorHosted.Server/Components/Layout/MainLayout.razor rename to playground/BlazorHosted/BlazorHosted.Client/Layout/MainLayout.razor diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/Layout/MainLayout.razor.css b/playground/BlazorHosted/BlazorHosted.Client/Layout/MainLayout.razor.css similarity index 100% rename from playground/BlazorHosted/BlazorHosted.Server/Components/Layout/MainLayout.razor.css rename to playground/BlazorHosted/BlazorHosted.Client/Layout/MainLayout.razor.css diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/Layout/NavMenu.razor b/playground/BlazorHosted/BlazorHosted.Client/Layout/NavMenu.razor similarity index 100% rename from playground/BlazorHosted/BlazorHosted.Server/Components/Layout/NavMenu.razor rename to playground/BlazorHosted/BlazorHosted.Client/Layout/NavMenu.razor diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/Layout/NavMenu.razor.css b/playground/BlazorHosted/BlazorHosted.Client/Layout/NavMenu.razor.css similarity index 100% rename from playground/BlazorHosted/BlazorHosted.Server/Components/Layout/NavMenu.razor.css rename to playground/BlazorHosted/BlazorHosted.Client/Layout/NavMenu.razor.css diff --git a/playground/BlazorHosted/BlazorHosted.Client/Pages/Counter.razor b/playground/BlazorHosted/BlazorHosted.Client/Pages/Counter.razor index 6b9e8cb4511..78e1fdba4ad 100644 --- a/playground/BlazorHosted/BlazorHosted.Client/Pages/Counter.razor +++ b/playground/BlazorHosted/BlazorHosted.Client/Pages/Counter.razor @@ -1,5 +1,4 @@ -@page "/counter" -@rendermode InteractiveWebAssembly +@page "/counter" Counter diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/Pages/Home.razor b/playground/BlazorHosted/BlazorHosted.Client/Pages/Home.razor similarity index 100% rename from playground/BlazorHosted/BlazorHosted.Server/Components/Pages/Home.razor rename to playground/BlazorHosted/BlazorHosted.Client/Pages/Home.razor diff --git a/playground/BlazorHosted/BlazorHosted.Client/Pages/Time.razor b/playground/BlazorHosted/BlazorHosted.Client/Pages/Time.razor index a3149a44faf..86d9c9e7cbb 100644 --- a/playground/BlazorHosted/BlazorHosted.Client/Pages/Time.razor +++ b/playground/BlazorHosted/BlazorHosted.Client/Pages/Time.razor @@ -1,5 +1,4 @@ @page "/time" -@rendermode InteractiveWebAssembly @inject IHttpClientFactory HttpClientFactory Time diff --git a/playground/BlazorHosted/BlazorHosted.Client/Pages/Weather.razor b/playground/BlazorHosted/BlazorHosted.Client/Pages/Weather.razor index 57d8911e7b3..4d35dbb88cd 100644 --- a/playground/BlazorHosted/BlazorHosted.Client/Pages/Weather.razor +++ b/playground/BlazorHosted/BlazorHosted.Client/Pages/Weather.razor @@ -1,5 +1,4 @@ -@page "/weather" -@rendermode InteractiveWebAssembly +@page "/weather" @inject IHttpClientFactory HttpClientFactory Weather diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/Routes.razor b/playground/BlazorHosted/BlazorHosted.Client/Routes.razor similarity index 76% rename from playground/BlazorHosted/BlazorHosted.Server/Components/Routes.razor rename to playground/BlazorHosted/BlazorHosted.Client/Routes.razor index 12d411e8e1a..30c8b853229 100644 --- a/playground/BlazorHosted/BlazorHosted.Server/Components/Routes.razor +++ b/playground/BlazorHosted/BlazorHosted.Client/Routes.razor @@ -1,4 +1,4 @@ - + diff --git a/playground/BlazorHosted/BlazorHosted.Client/_Imports.razor b/playground/BlazorHosted/BlazorHosted.Client/_Imports.razor index ca3658853b2..84bea657094 100644 --- a/playground/BlazorHosted/BlazorHosted.Client/_Imports.razor +++ b/playground/BlazorHosted/BlazorHosted.Client/_Imports.razor @@ -7,3 +7,4 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop @using BlazorHosted.Client +@using BlazorHosted.Client.Layout diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/App.razor b/playground/BlazorHosted/BlazorHosted.Server/Components/App.razor index b2942971fdd..ec48054637c 100644 --- a/playground/BlazorHosted/BlazorHosted.Server/Components/App.razor +++ b/playground/BlazorHosted/BlazorHosted.Server/Components/App.razor @@ -13,7 +13,7 @@ - + diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/_Imports.razor b/playground/BlazorHosted/BlazorHosted.Server/Components/_Imports.razor index 8a41dca9994..c1fc3278f77 100644 --- a/playground/BlazorHosted/BlazorHosted.Server/Components/_Imports.razor +++ b/playground/BlazorHosted/BlazorHosted.Server/Components/_Imports.razor @@ -9,4 +9,3 @@ @using BlazorHosted @using BlazorHosted.Client @using BlazorHosted.Components -@using BlazorHosted.Components.Layout diff --git a/playground/BlazorHosted/BlazorHosted.Server/Program.cs b/playground/BlazorHosted/BlazorHosted.Server/Program.cs index 6acf13d0337..380b75a311c 100644 --- a/playground/BlazorHosted/BlazorHosted.Server/Program.cs +++ b/playground/BlazorHosted/BlazorHosted.Server/Program.cs @@ -47,11 +47,7 @@ var app = builder.Build(); // Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseWebAssemblyDebugging(); -} -else +if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseHsts(); diff --git a/playground/BlazorStandalone/BlazorStandalone/BlazorStandalone.csproj b/playground/BlazorStandalone/BlazorStandalone/BlazorStandalone.csproj index 3f55014904d..dff13e6b99e 100644 --- a/playground/BlazorStandalone/BlazorStandalone/BlazorStandalone.csproj +++ b/playground/BlazorStandalone/BlazorStandalone/BlazorStandalone.csproj @@ -4,6 +4,14 @@ net10.0 enable enable + + true + + 2 + + portable + true true true diff --git a/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj b/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj index 757c106895d..5606a3ae2e2 100644 --- a/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj +++ b/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj @@ -5,7 +5,7 @@ true aspire integration hosting blazor webassembly gateway Blazor WebAssembly hosting support for Aspire. - $(NoWarn);ASPIREBLAZOR001 + $(NoWarn);ASPIREBLAZOR001;CS0436 @@ -32,6 +32,9 @@ + + + diff --git a/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs index 99934ebaa3b..4d4d186b178 100644 --- a/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs @@ -171,6 +171,18 @@ public static IResourceBuilder WithBlazorClientApp( gateway.WithBlazorApp(wasmApp, pathPrefix, services, apiPrefix, otlpPrefix, proxyTelemetry); + // Register browser debugging support: create a hidden child debugger resource + // parented to the gateway, and a "Debug in Browser" command on the WASM app resource. + if (!gateway.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + BrowserDebuggerHelper.AddBrowserDebuggerResource( + gateway.ApplicationBuilder, + gateway.Resource, + wasmApp, + wasmApp.Resource.ProjectPath, + relativePath: pathPrefix); + } + return gateway; } diff --git a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs index be2d14e7b63..acea0a6a8b8 100644 --- a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Xml.Linq; using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Logging; @@ -79,6 +80,22 @@ private static void EnsureEnvironmentCallback( annotation.IsInitialized = true; + // Register "Debug in Browser" for the hosted WASM client automatically (idempotent). + if (!host.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + var debuggerName = $"{host.Resource.Name}-wasm-debugger"; + if (!host.ApplicationBuilder.Resources.Any(r => r.Name == debuggerName)) + { + var projectMetadata = host.Resource.GetProjectMetadata(); + // The debug bridge (monovsdbg_wasm) needs the CLIENT project path to resolve + // WASM BCL assemblies on disk. Passing the server's path fails because the + // server's output doesn't contain browser-wasm BCL DLLs (e.g. mscorlib.dll). + var clientProjectPath = ResolveBlazorWasmClientProjectPath(projectMetadata.ProjectPath) + ?? projectMetadata.ProjectPath; + AddBrowserDebuggerResource(host, clientProjectPath, relativePath: null); + } + } + host.WithEnvironment(context => { var httpsHostEndpoint = GetEndpointIfDefined(host.Resource, "https"); @@ -134,6 +151,67 @@ private static HashSet GetReferencedResourceNames(IResource resource) var endpoint = resource.GetEndpoint(endpointName); return endpoint.Exists ? endpoint : null; } + + /// + /// Resolves the Blazor WebAssembly client project path from the server project's references. + /// The server's .csproj contains a ProjectReference to the client which uses + /// Microsoft.NET.Sdk.BlazorWebAssembly. We find that reference so the debug bridge + /// can locate WASM BCL assemblies in the client's output directory. + /// + private static string? ResolveBlazorWasmClientProjectPath(string serverProjectPath) + { + try + { + var serverDir = Path.GetDirectoryName(serverProjectPath); + if (serverDir is null) + { + return null; + } + + var doc = XDocument.Load(serverProjectPath); + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + + var projectRefs = doc.Descendants(ns + "ProjectReference") + .Select(e => e.Attribute("Include")?.Value) + .Where(v => v is not null); + + foreach (var relPath in projectRefs) + { + var fullPath = Path.GetFullPath(Path.Combine(serverDir, relPath!)); + if (!File.Exists(fullPath)) + { + continue; + } + + // Check if this project uses the BlazorWebAssembly SDK + var refDoc = XDocument.Load(fullPath); + var sdk = refDoc.Root?.Attribute("Sdk")?.Value; + if (string.Equals(sdk, "Microsoft.NET.Sdk.BlazorWebAssembly", StringComparison.OrdinalIgnoreCase)) + { + return fullPath; + } + } + } + catch + { + // If we can't resolve the client project, fall back to the server path. + } + + return null; + } + + private static void AddBrowserDebuggerResource( + IResourceBuilder host, + string clientProjectPath, + string? relativePath) + { + BrowserDebuggerHelper.AddBrowserDebuggerResource( + host.ApplicationBuilder, + host.Resource, + host, + clientProjectPath, + relativePath); + } } /// diff --git a/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs b/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs new file mode 100644 index 00000000000..316e34e290f --- /dev/null +++ b/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs @@ -0,0 +1,260 @@ +// 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; +using Aspire.Hosting.JavaScript; +using Aspire.Hosting.Orchestrator; +using Microsoft.Extensions.DependencyInjection; + +#pragma warning disable ASPIREEXTENSION001 // WithDebugSupport is experimental + +namespace Aspire.Hosting; + +/// +/// Shared helper for creating browser debugger resources. +/// Both hosted Blazor (BlazorHostedExtensions) and gateway (BlazorGatewayExtensions) +/// use this to avoid duplicating the child-resource + command registration pattern. +/// +internal static class BrowserDebuggerHelper +{ + // TODO: Replace with the WebAssembly debugger executable once available. + private const string BrowserCommand = "msedge"; + + /// + /// Creates a hidden child ExecutableResource with WithExplicitStart that launches a debug browser + /// via DCP/IDE when started. Registers "Debug in Browser" and "Stop Browser Debug" commands + /// on the specified command target. + /// + /// The distributed application builder. + /// The resource that owns the endpoint to debug (gateway or host). + /// The resource on which to register the debug commands. + /// Absolute path to the WASM client .csproj. + /// Optional path prefix appended to the endpoint URL. + internal static void AddBrowserDebuggerResource( + IDistributedApplicationBuilder builder, + IResourceWithEndpoints parentResource, + IResourceBuilder commandTarget, + string clientProjectPath, + string? relativePath) + { + var debuggerResourceName = relativePath is not null + ? $"{parentResource.Name}-{commandTarget.Resource.Name}-debugger" + : $"{parentResource.Name}-wasm-debugger"; + + var clientProjectDir = Path.GetDirectoryName(clientProjectPath) ?? clientProjectPath; + + var debuggerResource = new BrowserDebuggerResource(debuggerResourceName, BrowserCommand, clientProjectDir); + + // Tracks whether a debug browser session is currently active. + // Toggled by the start/stop command handlers and reset when the resource stops + // (e.g., user closes the browser). + var debugSessionActive = false; + var watcherCts = new CancellationTokenSource(); + + builder.AddResource(debuggerResource) + .WithParentRelationship(parentResource) + .ExcludeFromManifest() + .WithExplicitStart() + .WithInitialState(new() + { + ResourceType = "BrowserDebugger", + Properties = [], + IsHidden = true + }) + .WithDebugSupport( + mode => + { + // Resolve the parent's endpoint at runtime to get the actual allocated URL. + 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 appUrl = relativePath is not null + ? $"{endpointReference.Url}/{relativePath}/" + : endpointReference.Url; + + return new BrowserLaunchConfiguration + { + Mode = mode, + Url = appUrl, + WebRoot = clientProjectPath, + Browser = BrowserCommand + }; + }, + "browser"); + + // Register "Debug in Browser" command — shown when no debug session is active. + commandTarget.WithCommand( + name: "debug-in-browser", + displayName: "Debug in Browser", + executeCommand: async context => + { + if (debugSessionActive) + { + return CommandResults.Success(); + } + + // Resolve the DCP instance name from the model resource's DcpInstancesAnnotation. + // StartResourceAsync expects the DCP metadata name (e.g., "gateway-app-debugger-abc123"), + // not the model resource name (e.g., "gateway-app-debugger"). + var dcpInstanceName = GetDcpInstanceName(debuggerResource); + var orchestrator = context.ServiceProvider.GetRequiredService(); + await orchestrator.StartResourceAsync(dcpInstanceName, context.CancellationToken).ConfigureAwait(false); + debugSessionActive = true; + + // Cancel any previous watcher before starting a new one. + await watcherCts.CancelAsync().ConfigureAwait(false); + watcherCts = new CancellationTokenSource(); + + // Publish a no-op update on the command target to force the dashboard to + // re-evaluate UpdateState callbacks and toggle command visibility. + var notificationService = context.ServiceProvider.GetRequiredService(); + await notificationService.PublishUpdateAsync(commandTarget.Resource, s => s).ConfigureAwait(false); + + // Watch for the debugger resource to stop (e.g., user closes the browser) + // so we can flip the flag and re-show the "Debug in Browser" command. + _ = WatchForDebuggerStopAsync(context.ServiceProvider, commandTarget.Resource, debuggerResource, watcherCts.Token, () => debugSessionActive = false); + + return CommandResults.Success(); + }, + commandOptions: new() + { + UpdateState = ctx => + { + if (debugSessionActive) + { + return ResourceCommandState.Hidden; + } + + return ctx.ResourceSnapshot.State?.Text == KnownResourceStates.Running + ? ResourceCommandState.Enabled + : ResourceCommandState.Disabled; + }, + IconName = "Bug", + IconVariant = IconVariant.Filled, + IsHighlighted = true + }); + + // Register "Stop Browser Debug" command — shown when a debug session is active. + commandTarget.WithCommand( + name: "stop-browser-debug", + displayName: "Stop Browser Debug", + executeCommand: async context => + { + // Cancel the watcher so it doesn't race with our state reset. + await watcherCts.CancelAsync().ConfigureAwait(false); + + var dcpInstanceName = GetDcpInstanceName(debuggerResource); + var orchestrator = context.ServiceProvider.GetRequiredService(); + await orchestrator.StopResourceAsync(dcpInstanceName, context.CancellationToken).ConfigureAwait(false); + debugSessionActive = false; + + // Force dashboard to re-evaluate command visibility. + var notificationService = context.ServiceProvider.GetRequiredService(); + await notificationService.PublishUpdateAsync(commandTarget.Resource, s => s).ConfigureAwait(false); + + return CommandResults.Success(); + }, + commandOptions: new() + { + UpdateState = ctx => + { + if (!debugSessionActive) + { + return ResourceCommandState.Hidden; + } + + return ResourceCommandState.Enabled; + }, + IconName = "DismissCircle", + IconVariant = IconVariant.Filled, + IsHighlighted = true + }); + } + + /// + /// Watches the debugger resource for a transition to stopped state (e.g., browser closed) + /// and invokes the callback to reset the active session flag. + /// Handles both the normal case (Running → terminal) and the immediate failure case + /// (Starting → FailedToStart without ever reaching Running). + /// + private static async Task WatchForDebuggerStopAsync( + IServiceProvider serviceProvider, + IResource commandTargetResource, + IResource debuggerResource, + CancellationToken cancellationToken, + Action onStopped) + { + var resourceNotificationService = serviceProvider.GetRequiredService(); + var wasStarted = false; + + try + { + await foreach (var evt in resourceNotificationService.WatchAsync(cancellationToken).ConfigureAwait(false)) + { + if (evt.Resource != debuggerResource) + { + continue; + } + + var state = evt.Snapshot.State?.Text; + + if (state == KnownResourceStates.Running || state == KnownResourceStates.Starting) + { + wasStarted = true; + continue; + } + + // Reset when the resource reaches a terminal state — either after running + // (normal browser close / crash) or immediately (failed to start). + // DCP executables use "Terminated" (killed by controller) and "Finished" (ran to completion). + // Explicit-start resources may also transition back to "NotStarted" after stopping. + var isTerminal = state == KnownResourceStates.Exited + || state == KnownResourceStates.Finished + || state == KnownResourceStates.FailedToStart + || state == "Terminated" + || (wasStarted && state == KnownResourceStates.NotStarted); + + if (isTerminal) + { + onStopped(); + + // Force dashboard to re-evaluate command visibility on the command target. + await resourceNotificationService.PublishUpdateAsync(commandTargetResource, s => s).ConfigureAwait(false); + break; + } + } + } + catch (OperationCanceledException) + { + // Expected when the watcher is cancelled (e.g., stop command or new debug session). + } + } + + /// + /// Resolves the DCP instance name from a resource's . + /// The DCP metadata name (e.g., "gateway-app-debugger-abc123") differs from the model resource + /// name (e.g., "gateway-app-debugger") because DCP appends a suffix during name generation. + /// + private static string GetDcpInstanceName(IResource resource) + { + if (resource.TryGetInstances(out var instances) && instances.Length > 0) + { + return instances[0].Name; + } + + // Fallback to the model resource name if instances haven't been populated yet. + return resource.Name; + } +} diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index eaa415dd086..e00d0310cf3 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -126,6 +126,8 @@ + +