Skip to content

[Combined review] Simplify browser tools to the provider contract - #56118

Draft
javiercn wants to merge 19 commits into
mainfrom
javiercn-browser-tools-combined-review
Draft

[Combined review] Simplify browser tools to the provider contract#56118
javiercn wants to merge 19 commits into
mainfrom
javiercn-browser-tools-combined-review

Conversation

@javiercn

@javiercn javiercn commented Sep 3, 2026

Copy link
Copy Markdown
Member

REVIEW ONLY — DO NOT MERGE

This OPEN DRAFT pull request is a combined review view plus an experimental browser-tools redesign. It is not an authoritative merge unit and must not be merged. Do not enable auto-merge.

The authoritative native stack remains #56076, with merge layers #56071, #56072, and #56080. This review branch is intentionally distinct from that stack.

The tree through 73667f3d1f086479f5d8babb52c7b3ed015f1253 matched the final authoritative stack head. The current review tree adds the experimental provider-only simplification, minimization, and validated session/key redesign.

Final experimental design

  • Browser client/config assets and the provider RSA public key are generated at build time into trusted, app-hosted static web assets. They are watch-build-only and are not published.
  • The provider serves no executable JavaScript. Its browser surface is limited to authenticated WebSocket /connect and /clear-cache.
  • The former /session.json, /updates/{generation}.json, protocolVersion, and wire-level generation/update IDs are removed.
  • Replay is serialized before live updates on the same authenticated WebSocket and under the same provider state lock, eliminating the cross-transport replay race.
  • Each watch invocation owns an ephemeral RSA key pair; the build receives only the public key. Missing, malformed, or undecryptable subprotocols are rejected before upgrade.
  • Standalone WebAssembly configures both launch paths: the Gateway ReverseProxy__* route and the inherited provider/hosting-startup forwarding environment. Gateway consumes the route; historical blazor-devserver consumes the forwarder.
  • .NET 9 receives the narrowly scoped runtime-agent compatibility flag and a local empty legacy replay response; net8 and net10+ retain their appropriate runtime/SDK-agent paths.
  • Browser apply failures and unavailable agents report failure instead of acknowledging a no-op. Applied console messages and Aspire notifications are emitted only when all clients applied successfully.
  • MVC/Razor Pages, Blazor server rendering, standalone WebAssembly, and hosted WebAssembly retain their app-model-specific activation paths while sharing the trusted client/provider protocol.

Authoritative validation

Validated exact head e75615ca747c077ad14f1e7a6c0db5a10b0683b2 after deleting the exact disposable bin/obj directories between candidate SDK transitions:

  • full redist build: passed with 0 warnings/errors;
  • isolated generated applications: 34/34 restored and built;
  • strengthened standalone net8/net9/net10/net11 gate: 4/4 passed;
  • ordinary run / suppressed-browser-refresh inactivity checks: 3/3 passed;
  • controlled net11 Gateway A/B/C: 3/3 matched the hypothesis:
    • route-only: active, authenticated /connect 101, observable C#/CSS updates;
    • forwarder-only: inactive because Gateway does not activate hosting startup;
    • combined: active, authenticated 101, observable C#/CSS updates;
  • clean final Playwright browser matrix: 34/34 passed, with 0 browser/page errors;
  • every case reported remainingPids: []; final matching process count: 0.

Every strengthened active case verified trusted app-hosted assets, a valid RSA-2048 key, authenticated app-origin /connect 101, watch-observed browser connection, missing/invalid subprotocol rejection with HTTP 400, removed endpoint 404s, zero executable resources loaded from the provider prefix, observable C# and CSS updates without host restart, and nonempty apply capabilities. Net9 alone enabled its compatibility flag and received the exact local [] replay response.

The prior apparent net10/net11 apply failure was reproduced and traced to stale downstream runtime assets retained in disposable app bin/obj; clean rebuilds fixed it. That condition also exposed and motivated the genuine false-success hardening described above.

The first full-matrix pass was 33/34 because the external synthetic net11-server/net10-client generator combined a net10 standalone import-map/fingerprint assumption with legacy UseStaticFiles. Only disposable external assets/generator logic were corrected; no product source changed. Focused retest and the clean full rerun passed 34/34.

Evidence root (external to the repository):
C:\Users\jacalvar\.copilot\session-state\72526d74-a3b0-4f9c-8057-e6902d92f75c\files\standalone-adjudication\candidate-e75615ca74

Key artifacts:

  • results\authoritative-summary.json
  • results\final-results.json
  • results\standalone-summary.json
  • results\gateway-abc-summary.json
  • results\clean-actions.json
  • results\mixed-110-100-clean-actions.json
  • results\asset-provenance.json
  • per-case JSON under results\browser; logs under logs

Please use this PR only to review the aggregate experimental design. Do not merge it.

javiercn and others added 9 commits September 2, 2026 12:30
Move standalone net11 Blazor WASM activation to a watch-owned provider reached through Blazor Gateway's existing reverse proxy.

Behavior: changed: standalone net11 WASM discovers provider-owned browser tooling without BrowserRefresh startup injection

State: builds; regression coverage follows in the next commit

Review hint: focus on provider discovery, generation replay, and the fixed Gateway route; the remainder is wiring

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 49a13c60-1d66-4321-bfdb-01275ead89d6
Add provider endpoint, replay-store, launch-selection, and build-only WASM asset coverage for the Gateway-native implementation.

Behavior: preserved

State: complete for PR #56071

Review hint: generated Static Web Assets baseline additions represent the two build-only hot reload modules

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 49a13c60-1d66-4321-bfdb-01275ead89d6
Use the built-in BodyTagHelper component pipeline to append one external browser-tools script for supported net11 MVC and Razor Pages responses.

Behavior: changed: supported MVC and Razor Pages activate browser tooling without generic HTML rewriting

State: builds; regression coverage follows in the next commit

Review hint: the supported boundary is the TagHelper registration and body-only PostContent append; static HTML remains user-activated

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 49a13c60-1d66-4321-bfdb-01275ead89d6
Cover body-only script insertion, hosting-startup registration, legacy fallback, and app-model selection for the MVC/Razor Pages boundary.

Behavior: preserved

State: complete for PR #56072

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 49a13c60-1d66-4321-bfdb-01275ead89d6
Move net11 server-hosted browser tooling onto the watch-owned protocol through a fixed loopback HTTP/WebSocket forwarder, including hosted WASM when both sides target net11.

Behavior: changed: modern server-hosted applications share provider-owned scripts, state, replay, and connection protocol

State: builds; regression coverage follows in the next commit

Review hint: verify the fixed destination/route, WebSocket subprotocol forwarding, and explicit TFM selection; legacy hosting startup remains isolated

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 49a13c60-1d66-4321-bfdb-01275ead89d6
Cover HTTP and WebSocket forwarding, provider-hosted resources, reconnect behavior, and explicit net8/net9/net10/net11 and mixed hosted-WASM launch strategies.

Behavior: preserved

State: complete for modern forwarding and compatibility selection

Review hint: the net8-net10 and mixed hosted-WASM rows prove that only all-net11 hosted applications select the provider path

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 49a13c60-1d66-4321-bfdb-01275ead89d6
Stop publishing the provider-hosted client as an application static asset, remove the obsolete legacy-injection switch and test-only launch facade, and reduce launch features to the managed-hot-reload decision.

Behavior: preserved: required pre-net11 and mixed hosted-WASM legacy paths remain selected and functional

State: complete

Review hint: the large baseline deletions are the removed static client asset; the client source remains embedded in and served by dotnet watch

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 49a13c60-1d66-4321-bfdb-01275ead89d6
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 49a13c60-1d66-4321-bfdb-01275ead89d6
Remove the legacy response-rewriting and application-hosted browser tooling paths, and use provider forwarding and replay across supported target frameworks.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@javiercn javiercn changed the title [Combined review] Unify browser tools activation and forwarding [Combined review] Simplify browser tools to the provider contract Sep 3, 2026
Remove the unreachable return after the provider-only reconnect loop.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
? HotReloadAppModel.InferFromProject(context, projectRootNode) as WebApplicationAppModel
: null;
var browserRefreshServer = webAppModel != null
? await context.BrowserRefreshServerFactory.GetOrCreateBrowserRefreshServerAsync(projectRootNode!, webAppModel, shutdownCancellationToken)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Could we pass in the YARP config and the HOSTING_STARTUP unconditionally? Only one will be active anyways and that way we don't have to infer anything.

javiercn and others added 9 commits September 3, 2026 19:21
Reduce the diff against main without changing the intended provider-only
behavior (standalone WASM via the devserver Gateway, server-hosted apps via
forwarding):

- Revert DotNetWatchBuild propagation; reserve only DotNetWatchBrowserTools.
- Revert the UsingBrowserRefreshMiddleware -> UsingBrowserTools message rename.
- Replace the 3-file IBrowserToolsLaunchConfigurator abstraction with a virtual
  WebApplicationAppModel.ConfigureBrowserToolsLaunchEnvironment method.
- Drop BrowserToolsForwarderOptions, IBrowserToolsUpdateStore, the unused
  browser-tools sessionId, and the duplicate JsonDelta transport DTO.
- Restore WebServerHost/KestrelWebSocketServer/app models close to main and undo
  formatting-only wrapping.
- Rebase the browser client on main's WebSocketScriptInjection.js as a git
  rename, preserving the toast UI, glyphs, sentinel and console messages, and
  restore the agent JS module and InitializeAsync(baseUri) export.
- Add a WasmSdk watch activation initializer for every supported TFM so net8/
  net9 standalone WASM still activates browser tools after legacy response
  injection was removed; net10+ keeps the Hot Reload agent path. Remove the Web
  SDK initializer in favor of the existing TagHelper.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Restore the Web SDK build-only JS initializer and its focused integration
test. BrowserRefreshTagHelperComponent only runs for MVC/Razor TagHelper
processing, so a Blazor Web App using static SSR or Interactive Server
without a WebAssembly client had no activation path. Deduplication stays in
the browser client.

Make the agent initializer read the dotnet-watch activation flag through a
function instead of capturing it at module evaluation, because library
initializer modules can be evaluated in any order.

Start the browser-tools bootstrap from the WebAssembly activation
initializer's onRuntimeReady hook instead of a top-level fire-and-forget
import, so the client connects after the runtime is up.

Make waitForHotReloadApply wait while neither apply function exists,
including while window.Blazor is absent, and report failure instead of
acknowledging replayed or live updates that could not be applied.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4e3d4dfa-da6c-410a-9cbe-d09d896af75b
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 73e5ae8a-3258-40da-9a27-a1fb7a8feffe
- DotNetWatcher/HotReloadClients go back to main's
  `browserRefreshServer?.ConfigureLaunchEnvironment(environmentBuilder)`. The
  app-model-specific configuration is passed to the refresh server as a callback
  when the server is created, so both the hot reload and the `--no-hot-reload`
  watch loops configure the launch environment exactly as they do in main.
- The WASM watch initializer and the Hot Reload agent initializer no longer
  communicate through `globalThis`. The watch initializer writes the runtime
  configuration variables the agent already looks for from
  `onRuntimeConfigLoaded`; the agent reads them from `onRuntimeReady`. Blazor
  runs every `onRuntimeConfigLoaded` before any `onRuntimeReady` and shares one
  config object, so this is ordered without relying on module load order. A
  cross-module import is not viable: the agent module only exists on .NET 10+,
  the watch module only exists under watch, and both URLs are fingerprinted.
- The browser client keeps its `WebSocketScriptInjection.js` name so git
  recognizes the move into the shared HotReload client project.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4e3d4dfa-da6c-410a-9cbe-d09d896af75b
Standalone WebAssembly no longer needs its own Gateway/YARP launch configuration
(the ReverseProxy route returns 404 on every host), so `ConfigureLaunchEnvironment`
has a single implementation again. Restore main's shape instead of routing it
through the app model:

- `AbstractBrowserRefreshServer` takes `middlewareAssemblyPath` again and
  configures the forwarding environment itself; the callback parameter and
  `WebApplicationAppModel.ConfigureBrowserToolsLaunchEnvironment` are gone.
- `BlazorWebAssemblyAppModel` is now identical to main.
- `BrowserRefreshServerTests`, deleted earlier on this branch, is restored and
  covers the provider launch environment; the project-graph-based
  `BlazorWebAssemblyAppModelTests` it superseded is removed. Both test mocks go
  back to main's constructor shape.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4e3d4dfa-da6c-410a-9cbe-d09d896af75b
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 73e5ae8a-3258-40da-9a27-a1fb7a8feffe
Executable browser tools JavaScript is now served only by the application build
output. The provider being authenticated no longer serves any code, so the
build pinned public key it is authenticated with is meaningful.

dotnet watch creates one RSA key pair per invocation before any project is
built and flows the public half through the reserved MSBuild property
DotNetWatchBrowserToolsPublicKey. A shared Static Web Assets targets file
generates a build only, publish excluded configuration module under obj that
pins that public key and the fixed provider route, and imports the separate
application hosted client module. The private key never leaves the watch
process and the secret the browser generates is never persisted.

Replay moves into the authenticated WebSocket handshake: the provider sends the
current snapshot first and releases queued live messages only after the browser
acknowledges it, per connection and in parallel across connections. That makes
session.json, protocol version negotiation, the HTTP updates endpoint and the
wire level generation id unnecessary, so all of them are removed along with the
provider hosted client script.

Also restores the Blazor Gateway reverse proxy route for standalone
WebAssembly, which cannot use ASP.NET Core hosting startups.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ca6d4419-8728-4fbe-8392-fae213d6efa0
…hot reload

Three adjudication-backed corrections on top of the browser-tools session redesign.

BrowserToolsForwarder turned the provider's deliberate pre-upgrade HTTP 400 for an
invalid encrypted WebSocket subprotocol into an app-origin 502, hiding the rejection
contract from the browser. It now opts into CollectHttpResponseDetails and relays the
provider's status when the upgrade failed with an error status, matching what
ForwardHttpAsync already does for plain HTTP. A status recorded after the provider
switched protocols (101) and an absent status both stay 502, so a genuine transport
failure is never reported as an authentication failure. Both APIs are .NET 7+ while
the assembly targets net6.0, so they are read through cached reflection.

.NET 9 standalone WebAssembly authenticated and reported successful delta delivery,
but no edit took effect. Its WebAssemblyHotReload creates the hot reload agent only
inside InitializeAsync, which the runtime runs only when __ASPNETCORE_BROWSER_TOOLS is
set; without it applyHotReloadDeltas is a silent no-op. The WebAssembly activation
initializer became a checked-in template whose gate the SDK substitutes for target
framework version 9.0 alone: .NET 8 must not get the variable because it would import
the removed blazor-hotreload.js module, and .NET 10+ ships its own agent.

That initialization path also probes /_framework/blazor-hotreload for previously
applied deltas. The redesign removed the endpoint, so the SPA fallback answered with
HTML and the runtime failed to parse it. The injected middleware now answers the route
locally with an empty update array. It is never forwarded to the provider and never
carries deltas: replay belongs to the authenticated WebSocket, and serving updates over
an unauthenticated route would be a security regression.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ca6d4419-8728-4fbe-8392-fae213d6efa0
dotnet watch could report "C# and Razor changes applied" while nothing was
applied. WebAssemblyHotReload.ApplyHotReloadDeltas returned an empty log when
the managed agent was null, the browser client turned that into a successful
acknowledgement, and watch reports an acknowledged update as applied.

- Record why the agent was not created and throw that reason from
  ApplyHotReloadDeltas instead of returning an empty log.
- Suppress the applied message and the Aspire notification when any client's
  apply task failed, so the console and the notification channel agree.
- Report a replay apply failure as an error without failing initialization: the
  provider disposes a connection whose initialization failed and the client
  reloads on close, which would loop.
- Create window.Blazor._internal unconditionally in the agent initializer; the
  nested guard threw when Blazor existed without _internal.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ca6d4419-8728-4fbe-8392-fae213d6efa0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant