Skip to content
代码片段 群组 项目
未验证 提交 146d316b 编辑于 作者: Pranav K's avatar Pranav K 提交者: GitHub
浏览文件

Add a middleware for browser refresh. (#24574)

* Add a middleware for browser refresh.

* Introduce a middleware that can connect to the dotnet-watch change server
* dotnet-watch: Inject the middleware in 3.1 or apps using start hooks \ hosting startup

https://github.com/dotnet/aspnetcore/issues/23412

* Update src/Tools/dotnet-watch/BrowserRefresh/src/StartupHook.cs

* Changes per PR comments

* Add a test for reading the script

* Changes per PR comments

* Updates docs

* Fixup test

* Add project ref
上级 b6540517
No related branches found
No related tags found
无相关合并请求
显示
1251 个添加3 个删除
......@@ -1445,6 +1445,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.NET.Sdk.BlazorWeb
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.NET.Sdk.BlazorWebAssembly.Tools", "src\Components\WebAssembly\Sdk\tools\Microsoft.NET.Sdk.BlazorWebAssembly.Tools.csproj", "{175E5CD8-92D4-46BB-882E-3A930D3302D4}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Watch.BrowserRefresh", "src\Tools\dotnet-watch\BrowserRefresh\src\Microsoft.AspNetCore.Watch.BrowserRefresh.csproj", "{A5CE25E9-89E1-4F2C-9B89-0C161707E700}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Watch.BrowserRefresh.Tests", "src\Tools\dotnet-watch\BrowserRefresh\test\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj", "{E6A23627-8D63-4DF1-A4F2-8881172C1FE6}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
......@@ -6843,6 +6847,30 @@ Global
{175E5CD8-92D4-46BB-882E-3A930D3302D4}.Release|x64.Build.0 = Release|Any CPU
{175E5CD8-92D4-46BB-882E-3A930D3302D4}.Release|x86.ActiveCfg = Release|Any CPU
{175E5CD8-92D4-46BB-882E-3A930D3302D4}.Release|x86.Build.0 = Release|Any CPU
{A5CE25E9-89E1-4F2C-9B89-0C161707E700}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5CE25E9-89E1-4F2C-9B89-0C161707E700}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5CE25E9-89E1-4F2C-9B89-0C161707E700}.Debug|x64.ActiveCfg = Debug|Any CPU
{A5CE25E9-89E1-4F2C-9B89-0C161707E700}.Debug|x64.Build.0 = Debug|Any CPU
{A5CE25E9-89E1-4F2C-9B89-0C161707E700}.Debug|x86.ActiveCfg = Debug|Any CPU
{A5CE25E9-89E1-4F2C-9B89-0C161707E700}.Debug|x86.Build.0 = Debug|Any CPU
{A5CE25E9-89E1-4F2C-9B89-0C161707E700}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5CE25E9-89E1-4F2C-9B89-0C161707E700}.Release|Any CPU.Build.0 = Release|Any CPU
{A5CE25E9-89E1-4F2C-9B89-0C161707E700}.Release|x64.ActiveCfg = Release|Any CPU
{A5CE25E9-89E1-4F2C-9B89-0C161707E700}.Release|x64.Build.0 = Release|Any CPU
{A5CE25E9-89E1-4F2C-9B89-0C161707E700}.Release|x86.ActiveCfg = Release|Any CPU
{A5CE25E9-89E1-4F2C-9B89-0C161707E700}.Release|x86.Build.0 = Release|Any CPU
{E6A23627-8D63-4DF1-A4F2-8881172C1FE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E6A23627-8D63-4DF1-A4F2-8881172C1FE6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E6A23627-8D63-4DF1-A4F2-8881172C1FE6}.Debug|x64.ActiveCfg = Debug|Any CPU
{E6A23627-8D63-4DF1-A4F2-8881172C1FE6}.Debug|x64.Build.0 = Debug|Any CPU
{E6A23627-8D63-4DF1-A4F2-8881172C1FE6}.Debug|x86.ActiveCfg = Debug|Any CPU
{E6A23627-8D63-4DF1-A4F2-8881172C1FE6}.Debug|x86.Build.0 = Debug|Any CPU
{E6A23627-8D63-4DF1-A4F2-8881172C1FE6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E6A23627-8D63-4DF1-A4F2-8881172C1FE6}.Release|Any CPU.Build.0 = Release|Any CPU
{E6A23627-8D63-4DF1-A4F2-8881172C1FE6}.Release|x64.ActiveCfg = Release|Any CPU
{E6A23627-8D63-4DF1-A4F2-8881172C1FE6}.Release|x64.Build.0 = Release|Any CPU
{E6A23627-8D63-4DF1-A4F2-8881172C1FE6}.Release|x86.ActiveCfg = Release|Any CPU
{E6A23627-8D63-4DF1-A4F2-8881172C1FE6}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
......@@ -7567,6 +7595,8 @@ Global
{83371889-9A3E-4D16-AE77-EB4F83BC6374} = {FED4267E-E5E4-49C5-98DB-8B3F203596EE}
{525EBCB4-A870-470B-BC90-845306C337D1} = {FED4267E-E5E4-49C5-98DB-8B3F203596EE}
{175E5CD8-92D4-46BB-882E-3A930D3302D4} = {FED4267E-E5E4-49C5-98DB-8B3F203596EE}
{A5CE25E9-89E1-4F2C-9B89-0C161707E700} = {B6118E15-C37A-4B05-B4DF-97FE99790417}
{E6A23627-8D63-4DF1-A4F2-8881172C1FE6} = {B6118E15-C37A-4B05-B4DF-97FE99790417}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F}
......
......@@ -147,7 +147,7 @@
<KnownFrameworkReference Condition="'$(UseAspNetCoreSharedRuntime)' != 'true'" Remove="Microsoft.AspNetCore.App" />
<KnownFrameworkReference Remove="Microsoft.WindowsDesktop.App" />
<KnownFrameworkReference Condition="'$(UseAspNetCoreSharedRuntime)' == 'true'" Update="Microsoft.AspNetCore.App">
<KnownFrameworkReference Condition="'$(UseAspNetCoreSharedRuntime)' == 'true' AND '$(DoNotApplyWorkaroundsToMicrosoftAspNetCoreApp)' != 'true'" Update="Microsoft.AspNetCore.App">
<LatestRuntimeFrameworkVersion>$(SharedFxVersion)</LatestRuntimeFrameworkVersion>
<DefaultRuntimeFrameworkVersion Condition="'$(IsServicingBuild)' != 'true'">$(SharedFxVersion)</DefaultRuntimeFrameworkVersion>
<TargetingPackVersion Condition="'$(IsServicingBuild)' != 'true'">$(SharedFxVersion)</TargetingPackVersion>
......
......@@ -217,3 +217,54 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
License notice for West Wind Live Reload ASP.NET Core Middleware
=============================================
MIT License
-----------
Copyright (c) 2019-2020 West Wind Technologies
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
License notice for cli-spinners
=============================================
MIT License
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
\ No newline at end of file
......@@ -66,6 +66,7 @@
<ProjectReferenceProvider Include="Microsoft.AspNetCore.CookiePolicy" ProjectPath="$(RepoRoot)src\Security\CookiePolicy\src\Microsoft.AspNetCore.CookiePolicy.csproj" />
<ProjectReferenceProvider Include="Microsoft.Web.Xdt.Extensions" ProjectPath="$(RepoRoot)src\SiteExtensions\Microsoft.Web.Xdt.Extensions\src\Microsoft.Web.Xdt.Extensions.csproj" />
<ProjectReferenceProvider Include="dotnet-getdocument" ProjectPath="$(RepoRoot)src\Tools\dotnet-getdocument\src\dotnet-getdocument.csproj" />
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Watch.BrowserRefresh" ProjectPath="$(RepoRoot)src\Tools\dotnet-watch\BrowserRefresh\src\Microsoft.AspNetCore.Watch.BrowserRefresh.csproj" />
<ProjectReferenceProvider Include="Microsoft.Extensions.ApiDescription.Client" ProjectPath="$(RepoRoot)src\Tools\Extensions.ApiDescription.Client\src\Microsoft.Extensions.ApiDescription.Client.csproj" />
<ProjectReferenceProvider Include="Microsoft.Extensions.ApiDescription.Server" ProjectPath="$(RepoRoot)src\Tools\Extensions.ApiDescription.Server\src\Microsoft.Extensions.ApiDescription.Server.csproj" />
<ProjectReferenceProvider Include="Microsoft.AspNetCore.DeveloperCertificates.XPlat" ProjectPath="$(RepoRoot)src\Tools\FirstRunCertGenerator\src\Microsoft.AspNetCore.DeveloperCertificates.XPlat.csproj" />
......
......@@ -20,7 +20,7 @@
The web sdk adds an implicit framework reference. This removes it until we can update our build to use framework references.
-->
<ItemGroup>
<FrameworkReference Remove="Microsoft.AspNetCore.App" />
<FrameworkReference Remove="Microsoft.AspNetCore.App" Condition="'$(DoNotApplyWorkaroundsToMicrosoftAspNetCoreApp)' != 'true'" />
<!-- Required because the Razor SDK will generate attributes -->
<Reference Include="Microsoft.AspNetCore.Mvc" Condition="'$(UsingMicrosoftNETSdkWeb)' == 'true' AND '$(TargetFrameworkIdentifier)' == '.NETCoreApp' AND '$(GenerateRazorAssemblyInfo)' == 'true'" />
</ItemGroup>
......
......@@ -17,7 +17,11 @@
"src\\Tools\\dotnet-user-secrets\\src\\dotnet-user-secrets.csproj",
"src\\Tools\\dotnet-user-secrets\\test\\dotnet-user-secrets.Tests.csproj",
"src\\Tools\\dotnet-watch\\src\\dotnet-watch.csproj",
"src\\Tools\\dotnet-watch\\test\\dotnet-watch.Tests.csproj"
"src\\Tools\\dotnet-watch\\BrowserRefresh\\src\\Microsoft.AspNetCore.Watch.BrowserRefresh.csproj",
"src\\Tools\\dotnet-watch\\BrowserRefresh\\test\\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj",
"src\\Tools\\dotnet-watch\\test\\dotnet-watch.Tests.csproj",
"src\\Middleware\\StaticFiles\\src\\Microsoft.AspNetCore.StaticFiles.csproj",
"src\\Hosting\\TestHost\\src\\Microsoft.AspNetCore.TestHost.csproj"
]
}
}
\ No newline at end of file
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
namespace Microsoft.AspNetCore.Watch.BrowserRefresh
{
public class BrowserRefreshMiddleware
{
private static readonly MediaTypeHeaderValue _textHtmlMediaType = new MediaTypeHeaderValue("text/html");
private readonly RequestDelegate _next;
private readonly ILogger _logger;
public BrowserRefreshMiddleware(RequestDelegate next, ILogger<BrowserRefreshMiddleware> logger) =>
(_next, _logger) = (next, logger);
public async Task InvokeAsync(HttpContext context)
{
// We only need to support this for requests that could be initiated by a browser.
if (IsBrowserRequest(context))
{
// Use a custom StreamWrapper to rewrite output on Write/WriteAsync
using var responseStreamWrapper = new ResponseStreamWrapper(context, _logger);
var originalBodyFeature = context.Features.Get<IHttpResponseBodyFeature>();
context.Features.Set<IHttpResponseBodyFeature>(new StreamResponseBodyFeature(responseStreamWrapper));
try
{
await _next(context);
}
finally
{
context.Features.Set(originalBodyFeature);
}
if (responseStreamWrapper.IsHtmlResponse && _logger.IsEnabled(LogLevel.Debug))
{
if (responseStreamWrapper.ScriptInjectionPerformed)
{
Log.BrowserConfiguredForRefreshes(_logger);
}
else
{
Log.FailedToConfiguredForRefreshes(_logger);
}
}
}
else
{
await _next(context);
}
}
internal static bool IsBrowserRequest(HttpContext context)
{
var request = context.Request;
if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsPost(request.Method))
{
return false;
}
var typedHeaders = request.GetTypedHeaders();
if (!(typedHeaders.Accept is IList<MediaTypeHeaderValue> acceptHeaders))
{
return false;
}
for (var i = 0; i < acceptHeaders.Count; i++)
{
if (acceptHeaders[i].IsSubsetOf(_textHtmlMediaType))
{
return true;
}
}
return false;
}
internal static class Log
{
private static readonly Action<ILogger, Exception?> _setupResponseForBrowserRefresh = LoggerMessage.Define(
LogLevel.Debug,
new EventId(1, "SetUpResponseForBrowserRefresh"),
"Response markup is scheduled to include browser refresh script injection.");
private static readonly Action<ILogger, Exception?> _browserConfiguredForRefreshes = LoggerMessage.Define(
LogLevel.Debug,
new EventId(2, "BrowserConfiguredForRefreshes"),
"Response markup was updated to include browser refresh script injection.");
private static readonly Action<ILogger, Exception?> _failedToConfigureForRefreshes = LoggerMessage.Define(
LogLevel.Debug,
new EventId(3, "FailedToConfiguredForRefreshes"),
"Unable to configure browser refresh script injection on the response.");
public static void SetupResponseForBrowserRefresh(ILogger logger) => _setupResponseForBrowserRefresh(logger, null);
public static void BrowserConfiguredForRefreshes(ILogger logger) => _browserConfiguredForRefreshes(logger, null);
public static void FailedToConfiguredForRefreshes(ILogger logger) => _failedToConfigureForRefreshes(logger, null);
}
}
}
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
[assembly: HostingStartup(typeof(Microsoft.AspNetCore.Watch.BrowserRefresh.HostingStartup))]
namespace Microsoft.AspNetCore.Watch.BrowserRefresh
{
internal sealed class HostingStartup : IHostingStartup, IStartupFilter
{
public void Configure(IWebHostBuilder builder)
{
builder.ConfigureServices(services => services.TryAddEnumerable(ServiceDescriptor.Singleton<IStartupFilter>(this)));
}
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return app =>
{
app.UseMiddleware<BrowserRefreshMiddleware>();
next(app);
};
}
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- This feature is supported in projects targeting 3.1 or later.-->
<TargetFramework>netcoreapp3.1</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<IsShipping>false</IsShipping>
<UseAspNetCoreSharedRuntime>true</UseAspNetCoreSharedRuntime>
<DoNotApplyWorkaroundsToMicrosoftAspNetCoreApp>true</DoNotApplyWorkaroundsToMicrosoftAspNetCoreApp>
<ExcludeFromSourceBuild>false</ExcludeFromSourceBuild>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<EmbeddedResource Include="WebSocketScriptInjection.js" />
</ItemGroup>
</Project>
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
// Based on https://github.com/RickStrahl/Westwind.AspnetCore.LiveReload/blob/128b5f524e86954e997f2c453e7e5c1dcc3db746/Westwind.AspnetCore.LiveReload/ResponseStreamWrapper.cs
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
namespace Microsoft.AspNetCore.Watch.BrowserRefresh
{
/// <summary>
/// Wraps the Response Stream to inject the WebSocket HTML into
/// an HTML Page.
/// </summary>
public class ResponseStreamWrapper : Stream
{
private static readonly MediaTypeHeaderValue _textHtmlMediaType = new MediaTypeHeaderValue("text/html");
private readonly Stream _baseStream;
private readonly HttpContext _context;
private readonly ILogger _logger;
private bool? _isHtmlResponse;
public ResponseStreamWrapper(HttpContext context, ILogger logger)
{
_context = context;
_baseStream = context.Response.Body;
_logger = logger;
}
public override bool CanRead => false;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length { get; }
public override long Position { get; set; }
public bool ScriptInjectionPerformed { get; private set; }
public bool IsHtmlResponse => _isHtmlResponse ?? false;
public override void Flush()
{
OnWrite();
_baseStream.Flush();
}
public override Task FlushAsync(CancellationToken cancellationToken)
{
OnWrite();
return _baseStream.FlushAsync(cancellationToken);
}
public override void Write(ReadOnlySpan<byte> buffer)
{
OnWrite();
if (IsHtmlResponse && !ScriptInjectionPerformed)
{
ScriptInjectionPerformed = WebSocketScriptInjection.Instance.TryInjectLiveReloadScript(_baseStream, buffer);
}
else
{
_baseStream.Write(buffer);
}
}
public override void WriteByte(byte value)
{
OnWrite();
_baseStream.WriteByte(value);
}
public override void Write(byte[] buffer, int offset, int count)
{
OnWrite();
if (IsHtmlResponse && !ScriptInjectionPerformed)
{
ScriptInjectionPerformed = WebSocketScriptInjection.Instance.TryInjectLiveReloadScript(_baseStream, buffer.AsSpan(offset, count));
}
else
{
_baseStream.Write(buffer, offset, count);
}
}
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
OnWrite();
if (IsHtmlResponse && !ScriptInjectionPerformed)
{
ScriptInjectionPerformed = await WebSocketScriptInjection.Instance.TryInjectLiveReloadScriptAsync(_baseStream, buffer.AsMemory(offset, count), cancellationToken);
}
else
{
await _baseStream.WriteAsync(buffer, offset, count, cancellationToken);
}
}
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
OnWrite();
if (IsHtmlResponse && !ScriptInjectionPerformed)
{
ScriptInjectionPerformed = await WebSocketScriptInjection.Instance.TryInjectLiveReloadScriptAsync(_baseStream, buffer, cancellationToken);
}
else
{
await _baseStream.WriteAsync(buffer, cancellationToken);
}
}
private void OnWrite()
{
if (_isHtmlResponse.HasValue)
{
return;
}
var response = _context.Response;
_isHtmlResponse =
(response.StatusCode == StatusCodes.Status200OK || response.StatusCode == StatusCodes.Status500InternalServerError) &&
MediaTypeHeaderValue.TryParse(response.ContentType, out var mediaType) &&
mediaType.IsSubsetOf(_textHtmlMediaType) &&
(!mediaType.Charset.HasValue || mediaType.Charset.Equals("utf-8", StringComparison.OrdinalIgnoreCase));
if (_isHtmlResponse.Value)
{
BrowserRefreshMiddleware.Log.SetupResponseForBrowserRefresh(_logger);
// Since we're changing the markup content, reset the content-length
response.Headers.ContentLength = null;
}
}
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
}
}
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
internal class StartupHook
{
public static void Initialize()
{
// This method exists to make startup hook load successfully. We do not need to do anything interesting here.
}
}
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.AspNetCore.Watch.BrowserRefresh
{
/// <summary>
/// Helper class that handles the HTML injection into
/// a string or byte array.
/// </summary>
public class WebSocketScriptInjection
{
private const string BodyMarker = "</body>";
private readonly byte[] _bodyBytes = Encoding.UTF8.GetBytes(BodyMarker);
private readonly byte[] _scriptInjectionBytes;
public static WebSocketScriptInjection Instance { get; } = new WebSocketScriptInjection(
GetWebSocketClientJavaScript(Environment.GetEnvironmentVariable("ASPNETCORE_AUTO_RELOAD_WS_ENDPOINT")));
public WebSocketScriptInjection(string clientScript)
{
_scriptInjectionBytes = Encoding.UTF8.GetBytes(clientScript);
}
public bool TryInjectLiveReloadScript(Stream baseStream, ReadOnlySpan<byte> buffer)
{
var index = buffer.LastIndexOf(_bodyBytes);
if (index == -1)
{
baseStream.Write(buffer);
return false;
}
if (index > 0)
{
baseStream.Write(buffer.Slice(0, index));
buffer = buffer[index..];
}
// Write the injected script
baseStream.Write(_scriptInjectionBytes);
// Write the rest of the buffer/HTML doc
baseStream.Write(buffer);
return true;
}
public async ValueTask<bool> TryInjectLiveReloadScriptAsync(Stream baseStream, ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
var index = buffer.Span.LastIndexOf(_bodyBytes);
if (index == -1)
{
await baseStream.WriteAsync(buffer, cancellationToken);
return false;
}
if (index > 0)
{
await baseStream.WriteAsync(buffer.Slice(0, index), cancellationToken);
buffer = buffer[index..];
}
// Write the injected script
await baseStream.WriteAsync(_scriptInjectionBytes, cancellationToken);
// Write the rest of the buffer/HTML doc
await baseStream.WriteAsync(buffer, cancellationToken);
return true;
}
internal static string GetWebSocketClientJavaScript(string? hostString)
{
var jsFileName = "Microsoft.AspNetCore.Watch.BrowserRefresh.WebSocketScriptInjection.js";
using var reader = new StreamReader(typeof(WebSocketScriptInjection).Assembly.GetManifestResourceStream(jsFileName)!);
var script = reader.ReadToEnd().Replace("{{hostString}}", hostString);
return $"<script>{script}</script>";
}
}
}
setTimeout(function () {
// dotnet-watch browser reload script
let connection;
try {
connection = new WebSocket('{{hostString}}');
} catch (ex) {
console.debug(ex);
return;
}
connection.onmessage = function (message) {
if (message.data === 'Reload') {
console.debug('Server is ready. Reloading...');
location.reload();
} else if (message.data === 'Wait') {
console.debug('File changes detected. Waiting for application to rebuild.');
const t = document.title; const r = ['', '', '']; let i = 0;
setInterval(function () { document.title = r[i++ % r.length] + ' ' + t; }, 240);
}
}
connection.onerror = function (event) { console.debug('dotnet-watch reload socket error.', event) }
connection.onclose = function () { console.debug('dotnet-watch reload socket closed.') }
connection.onopen = function () { console.debug('dotnet-watch reload socket connected.') }
}, 500);
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNetCore.Http;
using Xunit;
namespace Microsoft.AspNetCore.Watch.BrowserRefresh
{
public class BrowserRefreshMiddlewareTest
{
[Theory]
[InlineData("DELETE")]
[InlineData("head")]
[InlineData("Put")]
public void IsBrowserRequest_ReturnsFalse_ForNonGetOrPostRequests(string method)
{
// Arrange
var context = new DefaultHttpContext
{
Request =
{
Method = method,
Headers =
{
["Accept"] = "application/html",
},
},
};
// Act
var result = BrowserRefreshMiddleware.IsBrowserRequest(context);
// Assert
Assert.False(result);
}
[Fact]
public void IsBrowserRequest_ReturnsFalse_IsRequestDoesNotAcceptHtml()
{
// Arrange
var context = new DefaultHttpContext
{
Request =
{
Method = "GET",
Headers =
{
["Accept"] = "application/xml",
},
},
};
// Act
var result = BrowserRefreshMiddleware.IsBrowserRequest(context);
// Assert
Assert.False(result);
}
[Fact]
public void IsBrowserRequest_ReturnsTrue_ForGetRequestsThatAcceptHtml()
{
// Arrange
var context = new DefaultHttpContext
{
Request =
{
Method = "GET",
Headers =
{
["Accept"] = "application/json,text/html;q=0.9",
},
},
};
// Act
var result = BrowserRefreshMiddleware.IsBrowserRequest(context);
// Assert
Assert.True(result);
}
[Fact]
public void IsBrowserRequest_ReturnsTrue_ForRequestsThatAcceptAnyHtml()
{
// Arrange
var context = new DefaultHttpContext
{
Request =
{
Method = "Post",
Headers =
{
["Accept"] = "application/json,text/*+html;q=0.9",
},
},
};
// Act
var result = BrowserRefreshMiddleware.IsBrowserRequest(context);
// Assert
Assert.True(result);
}
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
<RootNamespace>Microsoft.AspNetCore.Watch.BrowserRefresh</RootNamespace>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.TestHost" />
<Reference Include="Microsoft.AspNetCore.Hosting" />
<Reference Include="Microsoft.AspNetCore.StaticFiles" />
</ItemGroup>
<ItemGroup>
<!--
The M.AspNetCore.App 3.1 framework reference in the BrowserReferesh project makes it difficult to reference it from the test project
We'll simply test against it as source.
-->
<Compile Include="..\src\BrowserRefreshMiddleware.cs" LinkBase="src" />
<Compile Include="..\src\ResponseStreamWrapper.cs" LinkBase="src" />
<Compile Include="..\src\WebSocketScriptInjection.cs" LinkBase="src" />
<EmbeddedResource Include="..\src\WebSocketScriptInjection.js" />
</ItemGroup>
<ItemGroup>
<Content Include="wwwroot\*" CopyToOutputDirectory="PreserveNewest" Pack="true" />
</ItemGroup>
<ItemGroup>
<None Remove="wwwroot\favicon.ico" />
</ItemGroup>
</Project>
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Xunit;
namespace Microsoft.AspNetCore.Watch.BrowserRefresh.Tests
{
public class ResponseStreamWrapperTest
{
private const string BrowserAcceptHeader = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9";
[Fact]
public async Task HtmlIsInjectedForStaticFiles()
{
// Arrange
using var host = await StartHostAsync();
using var server = host.GetTestServer();
var response = await server.CreateRequest("/index.html").AddHeader("Accept", BrowserAcceptHeader).SendAsync("GET");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("dotnet-watch browser reload script", content);
}
[Fact]
public async Task HtmlIsInjectedForLargeStaticFiles()
{
// Arrange
using var host = await StartHostAsync();
using var server = host.GetTestServer();
var response = await server.CreateRequest("/largefile.html").AddHeader("Accept", BrowserAcceptHeader).SendAsync("GET");
// Assert
response.EnsureSuccessStatusCode();
Assert.False(response.Content.Headers.TryGetValues("Content-Length", out _), "We shouldn't send a Content-Length header.");
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("dotnet-watch browser reload script", content);
}
[Fact]
public async Task HtmlIsInjectedForDynamicallyGeneratedMarkup()
{
// Arrange
using var host = await StartHostAsync(routes =>
{
routes.MapGet("/dynamic-html", async context =>
{
context.Response.Headers["Content-Type"] = "text/html;charset=utf-8";
await context.Response.WriteAsync("<html><body><h1>Hello world</h1></body></html>");
});
});
using var server = host.GetTestServer();
var response = await server.CreateRequest("/dynamic-html").AddHeader("Accept", BrowserAcceptHeader).SendAsync("GET");
// Assert
response.EnsureSuccessStatusCode();
Assert.False(response.Content.Headers.TryGetValues("Content-Length", out _), "We shouldn't send a Content-Length header.");
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("dotnet-watch browser reload script", content);
}
[Fact]
public async Task HtmlIsInjectedForWriteAsyncMarkupWithContentLength()
{
// Arrange
var responseContent = Encoding.UTF8.GetBytes("<html><body><h1>Hello world</h1></body></html>");
using var host = await StartHostAsync(routes =>
{
routes.MapGet("/dynamic-html", async context =>
{
context.Response.ContentLength = responseContent.Length;
context.Response.Headers["Content-Type"] = "text/html;charset=utf-8";
await context.Response.Body.WriteAsync(responseContent);
});
});
using var server = host.GetTestServer();
var response = await server.CreateRequest("/dynamic-html").AddHeader("Accept", BrowserAcceptHeader).SendAsync("GET");
// Assert
response.EnsureSuccessStatusCode();
Assert.False(response.Content.Headers.TryGetValues("Content-Length", out _), "We shouldn't send a Content-Length header.");
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("dotnet-watch browser reload script", content);
}
[Fact]
public async Task HtmlIsInjectedForWriteAsyncMarkupWithMultipleWrites()
{
// Arrange
using var host = await StartHostAsync(routes =>
{
routes.MapGet("/dynamic-html", async context =>
{
context.Response.Headers["Content-Type"] = "text/html;charset=utf-8";
await context.Response.WriteAsync("<html>");
await context.Response.WriteAsync("<body>");
await context.Response.WriteAsync("<ul>");
for (var i = 0; i < 100; i++)
{
await context.Response.WriteAsync($"<li>{i}</li>");
}
await context.Response.WriteAsync("</ul>");
await context.Response.WriteAsync("</body>");
await context.Response.WriteAsync("</html>");
});
});
using var server = host.GetTestServer();
var response = await server.CreateRequest("/dynamic-html").AddHeader("Accept", BrowserAcceptHeader).SendAsync("GET");
// Assert
response.EnsureSuccessStatusCode();
Assert.False(response.Content.Headers.TryGetValues("Content-Length", out _), "We shouldn't send a Content-Length header.");
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("dotnet-watch browser reload script", content);
}
[Fact]
public async Task HtmlIsInjectedForPostResponses()
{
// Arrange
using var host = await StartHostAsync(routes =>
{
routes.MapPost("/mvc-view", async context =>
{
context.Response.Headers["Content-Type"] = "text/html;charset=utf-8";
await context.Response.WriteAsync("<html>");
await context.Response.WriteAsync("<body>");
await context.Response.WriteAsync("<ul>");
for (var i = 0; i < 100; i++)
{
await context.Response.WriteAsync($"<li>{i}</li>");
}
await context.Response.WriteAsync("</ul>");
await context.Response.WriteAsync("</body>");
await context.Response.WriteAsync("</html>");
});
});
using var server = host.GetTestServer();
var response = await server.CreateRequest("/mvc-view").AddHeader("Accept", BrowserAcceptHeader).SendAsync("POST");
// Assert
response.EnsureSuccessStatusCode();
Assert.False(response.Content.Headers.TryGetValues("Content-Length", out _), "We shouldn't send a Content-Length header.");
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("dotnet-watch browser reload script", content);
}
[Fact]
public async Task HtmlIsNotInjectedForNonBrowserRequests()
{
// Arrange
using var host = await StartHostAsync();
using var server = host.GetTestServer();
var response = await server.CreateRequest("/favicon.ico").AddHeader("Accept", "application/json").SendAsync("GET");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("dotnet-watch browser reload script", content);
}
[Fact]
public async Task HtmlIsNotInjectedForNonHtmlResponses()
{
// Arrange
using var host = await StartHostAsync();
using var server = host.GetTestServer();
var response = await server.CreateRequest("/favicon.ico").AddHeader("Accept", BrowserAcceptHeader).SendAsync("GET");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("dotnet-watch browser reload script", content);
}
[Fact]
public async Task HtmlIsNotInjectedForNon200Responses()
{
// Arrange
using var host = await StartHostAsync();
using var server = host.GetTestServer();
var response = await server.CreateRequest("/file-does-not-exist.html").AddHeader("Accept", BrowserAcceptHeader).SendAsync("GET");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("dotnet-watch browser reload script", content);
}
[Fact]
public async Task HtmlIsNotInjectedForNonGetOrPostResponses()
{
// Arrange
var responseContent = "<html><body><h1>Hello world</h1></body></html>";
using var host = await StartHostAsync(routes =>
{
routes.MapMethods(
"/dynamic-html",
new[] { "HEAD", "GET", "DELETE" },
async context =>
{
context.Response.Headers["Content-Type"] = "text/html;charset=utf-8";
await context.Response.WriteAsync(responseContent);
});
});
using var server = host.GetTestServer();
var response = await server.CreateRequest("/dynamic-html").AddHeader("Accept", BrowserAcceptHeader).SendAsync("HEAD");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("dotnet-watch browser reload script", content);
}
[Fact]
public async Task HtmlIsNotInjectedForJsonResponses()
{
// Arrange
var responseContent = "<html><body><h1>Hello world</h1></body></html>";
using var host = await StartHostAsync(routes =>
{
routes.MapGet(
"/dynamic-html",
async context =>
{
context.Response.Headers["Content-Type"] = "application/json;charset=utf-8";
await context.Response.WriteAsync(responseContent);
});
});
using var server = host.GetTestServer();
var response = await server.CreateRequest("/dynamic-html").AddHeader("Accept", BrowserAcceptHeader).SendAsync("GET");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("dotnet-watch browser reload script", content);
}
[Fact]
public async Task HtmlIsNotInjectedForNonUtf8Responses()
{
// Arrange
var responseContent = "<html><body><h1>Hello world</h1></body></html>";
using var host = await StartHostAsync(routes =>
{
routes.MapGet(
"/dynamic-html",
async context =>
{
context.Response.Headers["Content-Type"] = "text/html;charset=utf-16";
await context.Response.Body.WriteAsync(Encoding.Unicode.GetBytes(responseContent));
});
});
using var server = host.GetTestServer();
var response = await server.CreateRequest("/dynamic-html").AddHeader("Accept", BrowserAcceptHeader).SendAsync("GET");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("dotnet-watch browser reload script", content);
}
private static async Task<IHost> StartHostAsync(Action<IEndpointRouteBuilder>? routeBuilder = null)
{
var host = new HostBuilder()
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder
.UseTestServer()
.Configure(app =>
{
app.UseMiddleware<BrowserRefreshMiddleware>();
app.UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(Path.Combine(AppContext.BaseDirectory, "wwwroot")) });
if (routeBuilder != null)
{
app.UseRouting();
app.UseEndpoints(routeBuilder);
}
})
.ConfigureServices(services => services.AddRouting());
}).Build();
await host.StartAsync();
return host;
}
}
}
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Xunit;
namespace Microsoft.AspNetCore.Watch.BrowserRefresh
{
public class WebSockerScriptInjectionTest
{
private const string ClientScript = "<script><!--My cool script--></script>";
private readonly WebSocketScriptInjection ScriptInjection = new WebSocketScriptInjection(ClientScript);
[Fact]
public async Task TryInjectLiveReloadScriptAsync_DoesNotInjectMarkup_IfInputDoesNotContainBodyTag()
{
// Arrange
var stream = new MemoryStream();
var input = Encoding.UTF8.GetBytes("<div>this is not a real body tag.</div>");
// Act
var result = await ScriptInjection.TryInjectLiveReloadScriptAsync(stream, input);
// Assert
Assert.False(result);
Assert.Equal(input, stream.ToArray());
}
[Fact]
public async Task TryInjectLiveReloadScriptAsync_InjectsMarkupIfBodyTagAppearsInTheMiddle()
{
// Arrange
var expected =
$@"<footer>
This is the footer
</footer>
{ClientScript}</body>
</html>";
var stream = new MemoryStream();
var input = Encoding.UTF8.GetBytes(
@"<footer>
This is the footer
</footer>
</body>
</html>");
// Act
var result = await ScriptInjection.TryInjectLiveReloadScriptAsync(stream, input);
// Assert
Assert.True(result);
var output = Encoding.UTF8.GetString(stream.ToArray());
Assert.Equal(expected, output, ignoreLineEndingDifferences: true);
}
[Fact]
public async Task TryInjectLiveReloadScriptAsync_WithOffsetBodyTagAppearsInMiddle()
{
// Arrange
var expected = $"</table>{ClientScript}</body>";
var stream = new MemoryStream();
var input = Encoding.UTF8.GetBytes("unused</table></body>");
// Act
var result = await ScriptInjection.TryInjectLiveReloadScriptAsync(stream, input.AsMemory(6));
// Assert
Assert.True(result);
var output = Encoding.UTF8.GetString(stream.ToArray());
Assert.Equal(expected, output);
}
[Fact]
public async Task TryInjectLiveReloadScriptAsync_WithOffsetBodyTagAppearsAtStartOfOffset()
{
// Arrange
var expected = $"{ClientScript}</body>";
var stream = new MemoryStream();
var input = Encoding.UTF8.GetBytes("unused</body>");
// Act
var result = await ScriptInjection.TryInjectLiveReloadScriptAsync(stream, input.AsMemory(6));
// Assert
Assert.True(result);
var output = Encoding.UTF8.GetString(stream.ToArray());
Assert.Equal(expected, output);
}
[Fact]
public async Task TryInjectLiveReloadScriptAsync_InjectsMarkupIfBodyTagAppearsAtTheStartOfOutput()
{
// Arrange
var expected = $"{ClientScript}</body></html>";
var stream = new MemoryStream();
var input = Encoding.UTF8.GetBytes("</body></html>");
// Act
var result = await ScriptInjection.TryInjectLiveReloadScriptAsync(stream, input);
// Assert
Assert.True(result);
var output = Encoding.UTF8.GetString(stream.ToArray());
Assert.Equal(expected, output);
}
[Fact]
public async Task TryInjectLiveReloadScriptAsync_InjectsMarkupIfBodyTagAppearsByItself()
{
// Arrange
var expected = $"{ClientScript}</body>";
var stream = new MemoryStream();
var input = Encoding.UTF8.GetBytes("</body>");
// Act
var result = await ScriptInjection.TryInjectLiveReloadScriptAsync(stream, input);
// Assert
Assert.True(result);
var output = Encoding.UTF8.GetString(stream.ToArray());
Assert.Equal(expected, output);
}
[Fact]
public async Task TryInjectLiveReloadScriptAsync_MultipleBodyTags()
{
// Arrange
var expected = $"<p></body>some text</p>{ClientScript}</body>";
var stream = new MemoryStream();
var input = Encoding.UTF8.GetBytes("abc<p></body>some text</p></body>").AsMemory(3);
// Act
var result = await ScriptInjection.TryInjectLiveReloadScriptAsync(stream, input);
// Assert
Assert.True(result);
var output = Encoding.UTF8.GetString(stream.ToArray());
Assert.Equal(expected, output);
}
[Fact]
public void TryInjectLiveReloadScript_NoBodyTag()
{
// Arrange
var expected = "<p>Hello world</p>";
var stream = new MemoryStream();
var input = Encoding.UTF8.GetBytes(expected).AsSpan();
// Act
var result = ScriptInjection.TryInjectLiveReloadScript(stream, input);
// Assert
Assert.False(result);
var output = Encoding.UTF8.GetString(stream.ToArray());
Assert.Equal(expected, output);
}
[Fact]
public void TryInjectLiveReloadScript_NoOffset()
{
// Arrange
var expected = $"</table>{ClientScript}</body>";
var stream = new MemoryStream();
var input = Encoding.UTF8.GetBytes("</table></body>").AsSpan();
// Act
var result = ScriptInjection.TryInjectLiveReloadScript(stream, input);
// Assert
Assert.True(result);
var output = Encoding.UTF8.GetString(stream.ToArray());
Assert.Equal(expected, output);
}
[Fact]
public void TryInjectLiveReloadScript_WithOffset()
{
// Arrange
var expected = $"</table>{ClientScript}</body>";
var stream = new MemoryStream();
var input = Encoding.UTF8.GetBytes("unused</table></body>").AsSpan(6);
// Act
var result = ScriptInjection.TryInjectLiveReloadScript(stream, input);
// Assert
Assert.True(result);
var output = Encoding.UTF8.GetString(stream.ToArray());
Assert.Equal(expected, output);
}
[Fact]
public void GetWebSocketClientJavaScript_Works()
{
// Act
var script = WebSocketScriptInjection.GetWebSocketClientJavaScript("some-host");
// Assert
Assert.Contains("// dotnet-watch browser reload script", script);
Assert.Contains("'some-host'", script);
}
}
}
src/Tools/dotnet-watch/BrowserRefresh/test/wwwroot/favicon.ico

5.3 KB

<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>
<h1>Hello world</h1>
</body>
</html>
此差异已折叠。
0% 加载中 .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册