Skip to content

Commit b34c5dd

Browse files
authored
Prevent dotnet-watch script injection staleness (#27778)
The browser refresh mechansim used by dotnet-watch and VS modifies HTML content. The modified content includes a script block that has a WebSocket url that changes for each new execution of dotnet watch run (not rebuilds, but watch itself). HTML content can come from views or static html files on disk. For the latter, ASP.NET Core participates in browser caching by sending (and invalidating) etag headers. One way to fix this problem is remove or modify the etag headers. The risk here is that might cause differences in behavior in development users may come to rely on that are unavailable in production. This change instead modifies the HTML content so the output is always consistent and consequently safe to cache. The dynamic content is served separately by the injected middleware. This change fixes the issue of multiple instances of dotnet-watch. While this issue may crop up if you alternate between dotnet run and dotnet watch run but we haven't seen this being an issue as yet. Fixes #27548 Summary Running dotnet watch run multiple times in Blazor WASM apps (or any app that serves static html files) can produce console errors and prevent the browser refresh feature from working. Given that we've been telling our users to use dotnet watch run as their primary way to work outside of VS, it's likely more users would run in this. Customer impact A hard browser refresh (Ctrl + R) is needed to get the refresh behavior to work. Regression No. This has existed since the feature was introduced we did not get reports of it Risk Low. The fix is isolated to dotnet-watch and VS's browser refresh mechanism which is in preview. The change was tested locally, but if there's a regression or if the change interferes with user's workflow, users have the ability to disable this feature.
1 parent fc1ebaa commit b34c5dd

8 files changed

+157
-63
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Globalization;
6+
using System.IO;
7+
using System.Text;
8+
using System.Threading.Tasks;
9+
using Microsoft.AspNetCore.Http;
10+
11+
namespace Microsoft.AspNetCore.Watch.BrowserRefresh
12+
{
13+
/// <summary>
14+
/// Responds with the contennts of WebSocketScriptInjection.js with the stub WebSocket url replaced by the
15+
/// one specified by the launching app.
16+
/// </summary>
17+
public sealed class BrowserScriptMiddleware
18+
{
19+
private readonly byte[] _scriptBytes;
20+
private readonly string _contentLength;
21+
22+
public BrowserScriptMiddleware(RequestDelegate next)
23+
: this(Environment.GetEnvironmentVariable("ASPNETCORE_AUTO_RELOAD_WS_ENDPOINT")!)
24+
{
25+
}
26+
27+
internal BrowserScriptMiddleware(string webSocketUrl)
28+
{
29+
_scriptBytes = GetWebSocketClientJavaScript(webSocketUrl);
30+
_contentLength = _scriptBytes.Length.ToString(CultureInfo.InvariantCulture);
31+
}
32+
33+
public async Task InvokeAsync(HttpContext context)
34+
{
35+
context.Response.Headers["Cache-Control"] = "no-store";
36+
context.Response.Headers["Content-Length"] = _contentLength;
37+
context.Response.Headers["Content-Type"] = "application/javascript; charset=utf-8";
38+
39+
await context.Response.Body.WriteAsync(_scriptBytes.AsMemory(), context.RequestAborted);
40+
}
41+
42+
internal static byte[] GetWebSocketClientJavaScript(string hostString)
43+
{
44+
var jsFileName = "Microsoft.AspNetCore.Watch.BrowserRefresh.WebSocketScriptInjection.js";
45+
using var reader = new StreamReader(typeof(WebSocketScriptInjection).Assembly.GetManifestResourceStream(jsFileName)!);
46+
var script = reader.ReadToEnd().Replace("{{hostString}}", hostString);
47+
48+
return Encoding.UTF8.GetBytes(script);
49+
}
50+
}
51+
}

src/Tools/dotnet-watch/BrowserRefresh/src/HostingFilter.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Globalization;
56
using Microsoft.AspNetCore.Builder;
67
using Microsoft.AspNetCore.Hosting;
78
using Microsoft.Extensions.DependencyInjection;
@@ -22,6 +23,7 @@ public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
2223
{
2324
return app =>
2425
{
26+
app.Map(WebSocketScriptInjection.WebSocketScriptUrl, app1 => app1.UseMiddleware<BrowserScriptMiddleware>());
2527
app.UseMiddleware<BrowserRefreshMiddleware>();
2628
next(app);
2729
};

src/Tools/dotnet-watch/BrowserRefresh/src/ResponseStreamWrapper.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public override void Write(ReadOnlySpan<byte> buffer)
5858
OnWrite();
5959
if (IsHtmlResponse && !ScriptInjectionPerformed)
6060
{
61-
ScriptInjectionPerformed = WebSocketScriptInjection.Instance.TryInjectLiveReloadScript(_baseStream, buffer);
61+
ScriptInjectionPerformed = WebSocketScriptInjection.TryInjectLiveReloadScript(_baseStream, buffer);
6262
}
6363
else
6464
{
@@ -78,7 +78,7 @@ public override void Write(byte[] buffer, int offset, int count)
7878

7979
if (IsHtmlResponse && !ScriptInjectionPerformed)
8080
{
81-
ScriptInjectionPerformed = WebSocketScriptInjection.Instance.TryInjectLiveReloadScript(_baseStream, buffer.AsSpan(offset, count));
81+
ScriptInjectionPerformed = WebSocketScriptInjection.TryInjectLiveReloadScript(_baseStream, buffer.AsSpan(offset, count));
8282
}
8383
else
8484
{
@@ -92,7 +92,7 @@ public override async Task WriteAsync(byte[] buffer, int offset, int count, Canc
9292

9393
if (IsHtmlResponse && !ScriptInjectionPerformed)
9494
{
95-
ScriptInjectionPerformed = await WebSocketScriptInjection.Instance.TryInjectLiveReloadScriptAsync(_baseStream, buffer.AsMemory(offset, count), cancellationToken);
95+
ScriptInjectionPerformed = await WebSocketScriptInjection.TryInjectLiveReloadScriptAsync(_baseStream, buffer.AsMemory(offset, count), cancellationToken);
9696
}
9797
else
9898
{
@@ -106,7 +106,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, Cancella
106106

107107
if (IsHtmlResponse && !ScriptInjectionPerformed)
108108
{
109-
ScriptInjectionPerformed = await WebSocketScriptInjection.Instance.TryInjectLiveReloadScriptAsync(_baseStream, buffer, cancellationToken);
109+
ScriptInjectionPerformed = await WebSocketScriptInjection.TryInjectLiveReloadScriptAsync(_baseStream, buffer, cancellationToken);
110110
}
111111
else
112112
{

src/Tools/dotnet-watch/BrowserRefresh/src/WebSocketScriptInjection.cs

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,18 @@ namespace Microsoft.AspNetCore.Watch.BrowserRefresh
1313
/// Helper class that handles the HTML injection into
1414
/// a string or byte array.
1515
/// </summary>
16-
public class WebSocketScriptInjection
16+
public static class WebSocketScriptInjection
1717
{
1818
private const string BodyMarker = "</body>";
19+
internal const string WebSocketScriptUrl = "/_framework/aspnetcore-browser-refresh.js";
1920

20-
private readonly byte[] _bodyBytes = Encoding.UTF8.GetBytes(BodyMarker);
21-
private readonly byte[] _scriptInjectionBytes;
21+
private static readonly byte[] _bodyBytes = Encoding.UTF8.GetBytes(BodyMarker);
2222

23-
public static WebSocketScriptInjection Instance { get; } = new WebSocketScriptInjection(
24-
GetWebSocketClientJavaScript(Environment.GetEnvironmentVariable("ASPNETCORE_AUTO_RELOAD_WS_ENDPOINT")));
23+
internal static string InjectedScript { get; } = $"<script src=\"{WebSocketScriptUrl}\"></script>";
2524

26-
public WebSocketScriptInjection(string clientScript)
27-
{
28-
_scriptInjectionBytes = Encoding.UTF8.GetBytes(clientScript);
29-
}
25+
private static readonly byte[] _injectedScriptBytes = Encoding.UTF8.GetBytes(InjectedScript);
3026

31-
public bool TryInjectLiveReloadScript(Stream baseStream, ReadOnlySpan<byte> buffer)
27+
public static bool TryInjectLiveReloadScript(Stream baseStream, ReadOnlySpan<byte> buffer)
3228
{
3329
var index = buffer.LastIndexOf(_bodyBytes);
3430
if (index == -1)
@@ -44,14 +40,14 @@ public bool TryInjectLiveReloadScript(Stream baseStream, ReadOnlySpan<byte> buff
4440
}
4541

4642
// Write the injected script
47-
baseStream.Write(_scriptInjectionBytes);
43+
baseStream.Write(_injectedScriptBytes);
4844

4945
// Write the rest of the buffer/HTML doc
5046
baseStream.Write(buffer);
5147
return true;
5248
}
5349

54-
public async ValueTask<bool> TryInjectLiveReloadScriptAsync(Stream baseStream, ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
50+
public static async ValueTask<bool> TryInjectLiveReloadScriptAsync(Stream baseStream, ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
5551
{
5652
var index = buffer.Span.LastIndexOf(_bodyBytes);
5753
if (index == -1)
@@ -67,20 +63,12 @@ public async ValueTask<bool> TryInjectLiveReloadScriptAsync(Stream baseStream, R
6763
}
6864

6965
// Write the injected script
70-
await baseStream.WriteAsync(_scriptInjectionBytes, cancellationToken);
66+
await baseStream.WriteAsync(_injectedScriptBytes, cancellationToken);
7167

7268
// Write the rest of the buffer/HTML doc
7369
await baseStream.WriteAsync(buffer, cancellationToken);
7470
return true;
7571
}
7672

77-
internal static string GetWebSocketClientJavaScript(string? hostString)
78-
{
79-
var jsFileName = "Microsoft.AspNetCore.Watch.BrowserRefresh.WebSocketScriptInjection.js";
80-
using var reader = new StreamReader(typeof(WebSocketScriptInjection).Assembly.GetManifestResourceStream(jsFileName)!);
81-
var script = reader.ReadToEnd().Replace("{{hostString}}", hostString);
82-
83-
return $"<script>{script}</script>";
84-
}
8573
}
8674
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.IO;
5+
using System.Linq;
6+
using System.Text;
7+
using System.Threading.Tasks;
8+
using Microsoft.AspNetCore.Http;
9+
using Xunit;
10+
11+
namespace Microsoft.AspNetCore.Watch.BrowserRefresh
12+
{
13+
public class BrowserScriptMiddlewareTest
14+
{
15+
[Fact]
16+
public async Task InvokeAsync_ReturnsScript()
17+
{
18+
// Arrange
19+
var context = new DefaultHttpContext();
20+
var stream = new MemoryStream();
21+
context.Response.Body = stream;
22+
var middleware = new BrowserScriptMiddleware("some-host");
23+
24+
// Act
25+
await middleware.InvokeAsync(context);
26+
27+
// Assert
28+
stream.Position = 0;
29+
var script = Encoding.UTF8.GetString(stream.ToArray());
30+
Assert.Contains("// dotnet-watch browser reload script", script);
31+
Assert.Contains("'some-host'", script);
32+
}
33+
34+
[Fact]
35+
public async Task InvokeAsync_ConfiguresHeaders()
36+
{
37+
// Arrange
38+
var context = new DefaultHttpContext();
39+
context.Response.Body = new MemoryStream();
40+
var middleware = new BrowserScriptMiddleware("some-host");
41+
42+
// Act
43+
await middleware.InvokeAsync(context);
44+
45+
// Assert
46+
var response = context.Response;
47+
Assert.Collection(
48+
response.Headers.OrderBy(h => h.Key),
49+
kvp =>
50+
{
51+
Assert.Equal("Cache-Control", kvp.Key);
52+
Assert.Equal("no-store", kvp.Value);
53+
},
54+
kvp =>
55+
{
56+
Assert.Equal("Content-Length", kvp.Key);
57+
Assert.NotEmpty(kvp.Value);
58+
},
59+
kvp =>
60+
{
61+
Assert.Equal("Content-Type", kvp.Key);
62+
Assert.Equal("application/javascript; charset=utf-8", kvp.Value);
63+
});
64+
}
65+
}
66+
}

src/Tools/dotnet-watch/BrowserRefresh/test/Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
We'll simply test against it as source.
1919
-->
2020
<Compile Include="..\src\BrowserRefreshMiddleware.cs" LinkBase="src" />
21+
<Compile Include="..\src\BrowserScriptMiddleware.cs" LinkBase="src" />
2122
<Compile Include="..\src\ResponseStreamWrapper.cs" LinkBase="src" />
2223
<Compile Include="..\src\WebSocketScriptInjection.cs" LinkBase="src" />
2324
<EmbeddedResource Include="..\src\WebSocketScriptInjection.js" />

src/Tools/dotnet-watch/BrowserRefresh/test/ResponseStreamWrapperTest.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public async Task HtmlIsInjectedForStaticFiles()
3434
response.EnsureSuccessStatusCode();
3535

3636
var content = await response.Content.ReadAsStringAsync();
37-
Assert.Contains("dotnet-watch browser reload script", content);
37+
Assert.Contains(WebSocketScriptInjection.InjectedScript, content);
3838
}
3939

4040
[Fact]
@@ -50,7 +50,7 @@ public async Task HtmlIsInjectedForLargeStaticFiles()
5050

5151
Assert.False(response.Content.Headers.TryGetValues("Content-Length", out _), "We shouldn't send a Content-Length header.");
5252
var content = await response.Content.ReadAsStringAsync();
53-
Assert.Contains("dotnet-watch browser reload script", content);
53+
Assert.Contains(WebSocketScriptInjection.InjectedScript, content);
5454
}
5555

5656
[Fact]
@@ -73,7 +73,7 @@ public async Task HtmlIsInjectedForDynamicallyGeneratedMarkup()
7373

7474
Assert.False(response.Content.Headers.TryGetValues("Content-Length", out _), "We shouldn't send a Content-Length header.");
7575
var content = await response.Content.ReadAsStringAsync();
76-
Assert.Contains("dotnet-watch browser reload script", content);
76+
Assert.Contains(WebSocketScriptInjection.InjectedScript, content);
7777
}
7878

7979
[Fact]
@@ -98,7 +98,7 @@ public async Task HtmlIsInjectedForWriteAsyncMarkupWithContentLength()
9898

9999
Assert.False(response.Content.Headers.TryGetValues("Content-Length", out _), "We shouldn't send a Content-Length header.");
100100
var content = await response.Content.ReadAsStringAsync();
101-
Assert.Contains("dotnet-watch browser reload script", content);
101+
Assert.Contains(WebSocketScriptInjection.InjectedScript, content);
102102
}
103103

104104
[Fact]
@@ -131,7 +131,7 @@ public async Task HtmlIsInjectedForWriteAsyncMarkupWithMultipleWrites()
131131

132132
Assert.False(response.Content.Headers.TryGetValues("Content-Length", out _), "We shouldn't send a Content-Length header.");
133133
var content = await response.Content.ReadAsStringAsync();
134-
Assert.Contains("dotnet-watch browser reload script", content);
134+
Assert.Contains(WebSocketScriptInjection.InjectedScript, content);
135135
}
136136

137137
[Fact]
@@ -164,7 +164,7 @@ public async Task HtmlIsInjectedForPostResponses()
164164

165165
Assert.False(response.Content.Headers.TryGetValues("Content-Length", out _), "We shouldn't send a Content-Length header.");
166166
var content = await response.Content.ReadAsStringAsync();
167-
Assert.Contains("dotnet-watch browser reload script", content);
167+
Assert.Contains(WebSocketScriptInjection.InjectedScript, content);
168168
}
169169

170170
[Fact]

0 commit comments

Comments
 (0)