Skip to content
Open
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: 7 additions & 5 deletions .github/memory/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,14 @@ none of them. Apps that disable `StaticWebAssetsEnabled`, `JSModulesEnabled` or
property defaults to `true` for Debug builds and is the only build-time generation gate;
`dotnet watch` does not inject a browser-tools-specific MSBuild property. When generated,
the initializer first fetches
`/_framework/dotnet-browser-tools/hot-reload-settings.json` with `Cache-Control: no-store`
`/_framework/dotnet-browser-tools/hot-reload-settings.json` with `cache: 'no-store'`
semantics and imports the configuration module only when the response contains
`{ "hotReload": true }`. The watch provider owns that enabled response; the ASP.NET Core
runtime/host owns the disabled fallback for non-watch launches. The SDK and `dotnet watch`
do not emit that fallback. Watch does not activate the client by mutating an application
file.
`{ "hotReload": true }`. The watch provider owns that enabled response. For targets
earlier than .NET 11, the SDK emits a constant `{ "hotReload": false }` fallback at the
same stable route as a build-only, publish-excluded static asset. The early
hosting-startup forwarder or Gateway proxy order -1000 makes the live provider take
precedence. Newer targets retain the runtime/host's Development-only disabled fallback.
Watch does not activate the client by mutating an application file.

For hosted WebAssembly applications, the browser-facing client project owns the key,
initializer and configuration assets. The launching server consumes those referenced
Expand Down
14 changes: 8 additions & 6 deletions src/Dotnet.Watch/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,14 @@ Reload).
the build generates the browser-tools assets and defaults to `true` for Debug builds.
When present, the initializer fetches the provider-owned
`/_framework/dotnet-browser-tools/hot-reload-settings.json` route and starts the client
only for `{ "hotReload": true }`; the response is non-executable, contains no key
material, and is served with `Cache-Control: no-store`. The ASP.NET Core disabled
fallback for non-watch launches is supplied by the runtime/host, not by the SDK or
`dotnet watch`. Watch never activates the client by mutating an application file. Hosted
WebAssembly uses the client as the browser-tools project even though the server remains
the launching project.
only for `{ "hotReload": true }`; the provider's response is non-executable, contains
no key material, and is served with `Cache-Control: no-store`. The ASP.NET Core
disabled fallback for non-watch launches is supplied by a constant, build-only SDK
static asset for targets earlier than .NET 11 and by the runtime/host for newer
targets. The forwarder runs before static assets, and the Gateway provider proxy keeps
order -1000 so the live response wins. Watch never activates the client by mutating an
application file. Hosted WebAssembly uses the client as the browser-tools project even
though the server remains the launching project.
See
[`Microsoft.NET.Sdk.StaticWebAssets.HotReload.targets`](../StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.HotReload.targets).
Never move executable browser-tools code back into the provider, never let the private
Expand Down
15 changes: 12 additions & 3 deletions src/StaticWebAssetsSdk/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,18 @@ code, nor of the key. This target therefore also generates the keypair (via
the public half in the generated configuration module. The existing
`EnableHotReloadInRuntimeConfigDevFile` property controls generation and defaults to
`true` for Debug builds; there is no browser-tools-specific watch-to-MSBuild property.
The generated initializer checks the provider-owned
`/_framework/dotnet-browser-tools/hot-reload-settings.json` response before importing the
configuration; this response is not a generated file or Static Web Asset.
The generated initializer fetches
`/_framework/dotnet-browser-tools/hot-reload-settings.json` with `cache: 'no-store'`
and imports the configuration only when the response contains `{ "hotReload": true }`.

For target frameworks earlier than .NET 11, the SDK generates a constant
`{ "hotReload": false }` fallback as a build-only Static Web Asset at that stable,
unfingerprinted route. The active watch provider takes precedence through the
hosting-startup forwarder or the Gateway proxy's order of -1000. Watch never rewrites
the generated fallback. Projects targeting .NET 11 or later continue to rely on their
host's Development-only disabled endpoint, so this compatibility fallback does not
change their non-Development behavior.

Hosted WebAssembly's client owns these outputs; its server consumes the referenced assets
rather than generating a competing route and keypair. The assets are build only and must
never reach publish output. Its generated watch initializer also initializes the SDK Hot
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,48 @@ Copyright (c) .NET Foundation. All rights reserved.
<Output TaskParameter="Endpoints" ItemName="StaticWebAssetEndpoint" />
</DefineStaticWebAssetEndpoints>

<!-- Hosts before .NET 11 do not supply inactive settings.
The live Hot Reload provider takes precedence over this build-only fallback. -->
<PropertyGroup Condition="$([MSBuild]::VersionLessThan('$(TargetFrameworkVersion)', '11.0'))">
<_HotReloadBrowserToolsSettingsFallbackFile>$(_HotReloadBrowserToolsIntermediatePath)hot-reload-settings.json</_HotReloadBrowserToolsSettingsFallbackFile>
</PropertyGroup>

<WriteLinesToFile
Condition="'$(_HotReloadBrowserToolsSettingsFallbackFile)' != ''"
File="$(_HotReloadBrowserToolsSettingsFallbackFile)"
Lines="{ &quot;hotReload&quot;: false }"
Overwrite="true"
WriteOnlyWhenDifferent="true" />

<ItemGroup Condition="'$(_HotReloadBrowserToolsSettingsFallbackFile)' != ''">
<_HotReloadBrowserToolsSettingsFallback Include="$(_HotReloadBrowserToolsSettingsFallbackFile)">
<RelativePath>$(HotReloadBrowserToolsSettingsPath.TrimStart('/'))</RelativePath>
<OriginalItemSpec>$(_HotReloadBrowserToolsSettingsFallbackFile)</OriginalItemSpec>
</_HotReloadBrowserToolsSettingsFallback>
<FileWrites Include="$(_HotReloadBrowserToolsSettingsFallbackFile)" />
</ItemGroup>

<DefineStaticWebAssets
Condition="'@(_HotReloadBrowserToolsSettingsFallback)' != ''"
CandidateAssets="@(_HotReloadBrowserToolsSettingsFallback)"
FingerprintCandidates="false"
SourceId="$(PackageId)"
SourceType="Computed"
ContentRoot="$(_HotReloadBrowserToolsIntermediatePath)"
BasePath="/"
AssetKind="Build"
CopyToPublishDirectory="Never">
<Output TaskParameter="Assets" ItemName="StaticWebAsset" />
<Output TaskParameter="Assets" ItemName="_HotReloadBrowserToolsSettingsFallbackAsset" />
</DefineStaticWebAssets>

<DefineStaticWebAssetEndpoints
Condition="'@(_HotReloadBrowserToolsSettingsFallbackAsset)' != ''"
CandidateAssets="@(_HotReloadBrowserToolsSettingsFallbackAsset)"
ContentTypeMappings="@(StaticWebAssetContentTypeMapping)">
<Output TaskParameter="Endpoints" ItemName="StaticWebAssetEndpoint" />
</DefineStaticWebAssetEndpoints>

</Target>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ public class HotReloadBrowserToolsIntegrationTest : IsolatedNuGetPackageFolderAs
private const string ConfigFileName = "Microsoft.NET.Sdk.BlazorWeb.HotReload.BrowserTools.Config.js";
private const string ClientFileName = "Microsoft.NET.Sdk.BlazorWeb.HotReload.BrowserTools.js";
private const string InitializerFileName = "Microsoft.NET.Sdk.BlazorWeb.HotReload.lib.module.js";
private const string SettingsFileName = "hot-reload-settings.json";
private const string SettingsRelativePath = "_framework/dotnet-browser-tools/" + SettingsFileName;

// Contract with Microsoft.DotNet.Watch.BrowserToolsBuildOutputs.
private const string PublicKeyFileName = "browser-tools-key.public.json";
Expand All @@ -38,6 +40,156 @@ public class HotReloadBrowserToolsIntegrationTest : IsolatedNuGetPackageFolderAs
private string GeneratedDirectory(MSBuildCommand command)
=> Path.Combine(command.GetIntermediateDirectory(DefaultTfm, "Debug").ToString(), "HotReload");

[TestMethod]
[DataRow(TestAsset, "net8.0")]
[DataRow(TestAsset, "net10.0")]
[DataRow("BlazorWasmMinimal", "net8.0")]
[DataRow("BlazorWasmMinimal", "net10.0")]
public void Build_GeneratesDisabledSettingsFallbackForOlderTargetFrameworks(string testAsset, string targetFramework)
{
var projectDirectory = CreateAspNetSdkTestAsset(
testAsset, overrideTfm: targetFramework, identifier: $"{testAsset}-{targetFramework}");
var build = CreateBuildCommand(projectDirectory);

ExecuteCommand(build, "/p:StaticWebAssetsFingerprintContent=true").Should().Pass();

var intermediate = build.GetIntermediateDirectory(targetFramework, "Debug").ToString();
var manifest = StaticWebAssetsManifest.FromJsonBytes(
File.ReadAllBytes(Path.Combine(intermediate, "staticwebassets.build.json")));
var fallback = Assert.ContainsSingle(manifest.Assets.Where(
asset => asset.IsPrimaryAsset() && asset.RelativePath == SettingsRelativePath));

Assert.AreEqual(StaticWebAsset.AssetKinds.Build, fallback.AssetKind);
Assert.AreEqual(StaticWebAsset.AssetCopyOptions.Never, fallback.CopyToPublishDirectory);
Assert.AreEqual("/", fallback.BasePath);
using var settings = JsonDocument.Parse(File.ReadAllBytes(fallback.Identity));
Assert.IsFalse(settings.RootElement.GetProperty("hotReload").GetBoolean());

var endpoints = JsonSerializer.Deserialize(
File.ReadAllBytes(Path.Combine(intermediate, "staticwebassets.build.endpoints.json")),
StaticWebAssetsJsonSerializerContext.Default.StaticWebAssetEndpointsManifest);
var endpoint = Assert.ContainsSingle(endpoints.Endpoints.Where(
endpoint => endpoint.Route == SettingsRelativePath && endpoint.Selectors.Length == 0));
endpoint.ResponseHeaders.Should().Contain(
header => header.Name == "Content-Type" && header.Value == "application/json");
}

[TestMethod]
[DataRow(TestAsset)]
[DataRow("BlazorWasmMinimal")]
public void Build_CurrentTargetFrameworkDoesNotGenerateDisabledSettingsFallback(string testAsset)
{
var projectDirectory = CreateAspNetSdkTestAsset(testAsset, identifier: testAsset);
var build = CreateBuildCommand(projectDirectory);

ExecuteCommand(build).Should().Pass();

var intermediate = build.GetIntermediateDirectory(DefaultTfm, "Debug").ToString();
var manifest = StaticWebAssetsManifest.FromJsonBytes(
File.ReadAllBytes(Path.Combine(intermediate, "staticwebassets.build.json")));
manifest.Assets.Should().NotContain(asset => asset.RelativePath.Contains(SettingsFileName));
new FileInfo(Path.Combine(intermediate, "HotReload", SettingsFileName)).Should().NotExist();
}

[TestMethod]
[DataRow("/p:EnableHotReloadInRuntimeConfigDevFile=false")]
[DataRow("/p:Configuration=Release")]
[DataRow("/p:JSModulesEnabled=false")]
[DataRow("/p:StaticWebAssetsEnabled=false")]
public void Build_DisabledBrowserToolsDoNotGenerateSettingsFallback(string argument)
{
var projectDirectory = CreateAspNetSdkTestAsset(
TestAsset, overrideTfm: "net10.0", identifier: argument.Replace('/', '_').Replace(':', '_'));
var build = CreateBuildCommand(projectDirectory);

ExecuteCommand(build, argument).Should().Pass();

Directory.GetFiles(projectDirectory.TestRoot, SettingsFileName, SearchOption.AllDirectories)
.Should().BeEmpty();
}

[TestMethod]
public void Build_ReleaseWithHotReloadEnabledGeneratesDisabledSettingsFallbackForOlderTargetFramework()
{
var projectDirectory = CreateAspNetSdkTestAsset(TestAsset, overrideTfm: "net10.0");
var build = CreateBuildCommand(projectDirectory);

ExecuteCommand(build, "/p:Configuration=Release", "/p:EnableHotReloadInRuntimeConfigDevFile=true")
.Should().Pass();

var generated = Path.Combine(
build.GetIntermediateDirectory("net10.0", "Release").ToString(), "HotReload", SettingsFileName);
using var settings = JsonDocument.Parse(File.ReadAllBytes(generated));
Assert.IsFalse(settings.RootElement.GetProperty("hotReload").GetBoolean());
}

[TestMethod]
public void Rebuild_ReusesDisabledSettingsFallbackAndCleanRemovesIt()
{
var projectDirectory = CreateAspNetSdkTestAsset(TestAsset, overrideTfm: "net10.0");
var build = CreateBuildCommand(projectDirectory);

ExecuteCommand(build).Should().Pass();

var generated = Path.Combine(
build.GetIntermediateDirectory("net10.0", "Debug").ToString(), "HotReload", SettingsFileName);
var thumbprint = FileThumbPrint.Create(generated);
var lastWriteTime = File.GetLastWriteTimeUtc(generated);

ExecuteCommand(CreateBuildCommand(projectDirectory)).Should().Pass();

Assert.AreEqual(thumbprint, FileThumbPrint.Create(generated));
Assert.AreEqual(lastWriteTime, File.GetLastWriteTimeUtc(generated));

var clean = new MSBuildCommand(Log, "Clean", build.FullPathProjectFile);
ExecuteCommand(clean).Should().Pass();

new FileInfo(generated).Should().NotExist();
}

[TestMethod]
[DataRow(TestAsset)]
[DataRow("BlazorWasmMinimal")]
public void Publish_ExcludesDisabledSettingsFallbackForOlderTargetFramework(string testAsset)
{
var projectDirectory = CreateAspNetSdkTestAsset(
testAsset, overrideTfm: "net10.0", identifier: testAsset);
var build = CreateBuildCommand(projectDirectory);

ExecuteCommand(build).Should().Pass();

new FileInfo(Path.Combine(
build.GetIntermediateDirectory("net10.0", "Debug").ToString(), "HotReload", SettingsFileName))
.Should().Exist();

var publish = CreatePublishCommand(projectDirectory);
ExecuteCommand(publish).Should().Pass();

var manifest = File.ReadAllText(Path.Combine(
publish.GetIntermediateDirectory("net10.0", "Debug").ToString(), "staticwebassets.publish.json"));
manifest.Should().NotContain(SettingsFileName);
Directory.GetFiles(
publish.GetOutputDirectory("net10.0", "Debug").ToString(), SettingsFileName, SearchOption.AllDirectories)
.Should().BeEmpty();
}

[TestMethod]
public void Build_HostedWebAssemblyClientOwnsDisabledSettingsFallbackForOlderTargetFramework()
{
var projectDirectory = CreateAspNetSdkTestAsset("BlazorHosted", overrideTfm: "net10.0");
var build = CreateBuildCommand(projectDirectory, "blazorhosted");

ExecuteCommand(build).Should().Pass();

var intermediate = build.GetIntermediateDirectory("net10.0", "Debug").ToString();
new DirectoryInfo(Path.Combine(intermediate, "HotReload")).Should().NotExist();
var manifest = StaticWebAssetsManifest.FromJsonBytes(
File.ReadAllBytes(Path.Combine(intermediate, "staticwebassets.build.json")));
var fallback = Assert.ContainsSingle(manifest.Assets.Where(
asset => asset.IsPrimaryAsset() && asset.RelativePath == SettingsRelativePath));
Assert.AreEqual("blazorwasm", fallback.SourceId);
}

[TestMethod]
public void Build_GeneratesKeyPairInDeterministicIntermediatePaths()
{
Expand Down
Loading