Skip to content

Add support for launching a browser on file change #24220

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 24, 2020
Merged
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
108 changes: 108 additions & 0 deletions src/Tools/dotnet-watch/src/BrowserRefreshServer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// 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.Linq;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Tools.Internal;

namespace Microsoft.DotNet.Watcher.Tools
{
public class BrowserRefreshServer : IAsyncDisposable
{
private readonly IReporter _reporter;
private readonly TaskCompletionSource _taskCompletionSource;
private IHost _refreshServer;
private WebSocket _webSocket;

public BrowserRefreshServer(IReporter reporter)
{
_reporter = reporter;
_taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
}

public async ValueTask<string> StartAsync(CancellationToken cancellationToken)
{
_refreshServer = new HostBuilder()
.ConfigureWebHost(builder =>
{
builder.UseKestrel();
builder.UseUrls("http://127.0.0.1:0");

builder.Configure(app =>
{
app.UseWebSockets();
app.Run(WebSocketRequest);
});
})
.Build();

await _refreshServer.StartAsync(cancellationToken);

var serverUrl = _refreshServer.Services
.GetRequiredService<IServer>()
.Features
.Get<IServerAddressesFeature>()
.Addresses
.First();

return serverUrl.Replace("http://", "ws://");
}

private async Task WebSocketRequest(HttpContext context)
{
if (!context.WebSockets.IsWebSocketRequest)
Copy link
Member

Choose a reason for hiding this comment

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

Who is attaching with a WebSocket connection? (The browser I know, but how?)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There's a follow up which will inject a hosting startup via environment variable.

{
context.Response.StatusCode = 400;
return;
}

_webSocket = await context.WebSockets.AcceptWebSocketAsync();
await _taskCompletionSource.Task;
}

public async Task SendMessage(byte[] messageBytes, CancellationToken cancellationToken = default)
{
if (_webSocket == null || _webSocket.CloseStatus.HasValue)
{
return;
}

try
{
await _webSocket.SendAsync(messageBytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken);
}
catch (Exception ex)
{
_reporter.Output($"Refresh server error: {ex}");
}
}

public async ValueTask DisposeAsync()
{
if (_webSocket != null)
{
await _webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, default);
_webSocket.Dispose();
}

if (_refreshServer != null)
{
await _refreshServer.StopAsync();
_refreshServer.Dispose();
}

_taskCompletionSource.TrySetResult();
}
}
}
19 changes: 18 additions & 1 deletion src/Tools/dotnet-watch/src/DotNetWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

namespace Microsoft.DotNet.Watcher
{
public class DotNetWatcher
public class DotNetWatcher : IAsyncDisposable
{
private readonly IReporter _reporter;
private readonly ProcessRunner _processRunner;
Expand All @@ -31,6 +31,7 @@ public DotNetWatcher(IReporter reporter, IFileSetFactory fileSetFactory)
{
new MSBuildEvaluationFilter(fileSetFactory),
new NoRestoreFilter(),
new LaunchBrowserFilter(),
};
}

Expand Down Expand Up @@ -140,5 +141,21 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
}
}
}

public async ValueTask DisposeAsync()
{
foreach (var filter in _filters)
{
if (filter is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else if (filter is IDisposable diposable)
{
diposable.Dispose();
}

}
}
}
}
4 changes: 3 additions & 1 deletion src/Tools/dotnet-watch/src/IFileSet.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// 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.Collections.Generic;
Expand All @@ -7,6 +7,8 @@ namespace Microsoft.DotNet.Watcher
{
public interface IFileSet : IEnumerable<string>
{
bool IsNetCoreApp31OrNewer { get; }

bool Contains(string filePath);
}
}
7 changes: 5 additions & 2 deletions src/Tools/dotnet-watch/src/Internal/FileSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,19 @@ public class FileSet : IFileSet
{
private readonly HashSet<string> _files;

public FileSet(IEnumerable<string> files)
public FileSet(bool isNetCoreApp31OrNewer, IEnumerable<string> files)
{
IsNetCoreApp31OrNewer = isNetCoreApp31OrNewer;
_files = new HashSet<string>(files, StringComparer.OrdinalIgnoreCase);
}

public bool Contains(string filePath) => _files.Contains(filePath);

public int Count => _files.Count;

public static IFileSet Empty = new FileSet(Array.Empty<string>());
public bool IsNetCoreApp31OrNewer { get; }
Copy link
Member

Choose a reason for hiding this comment

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

What is stopping 2.1 from working?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The HTML injection middleware has to target whatever version we choose here. We could have it just launch the browser the first time, but that's not very useful in itself. Picking 3.1 seems like a good middleground.


public static IFileSet Empty = new FileSet(false, Array.Empty<string>());

public IEnumerator<string> GetEnumerator() => _files.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => _files.GetEnumerator();
Expand Down
9 changes: 7 additions & 2 deletions src/Tools/dotnet-watch/src/Internal/MsBuildFileSetFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public async Task<IFileSet> CreateAsync(CancellationToken cancellationToken)
Arguments = new[]
{
"msbuild",
"/nologo",
_projectFile,
$"/p:_DotNetWatchListFile={watchList}"
}.Concat(_buildFlags),
Expand All @@ -84,8 +85,12 @@ public async Task<IFileSet> CreateAsync(CancellationToken cancellationToken)

if (exitCode == 0 && File.Exists(watchList))
{
var lines = File.ReadAllLines(watchList);
var isNetCoreApp31OrNewer = lines.FirstOrDefault() == "true";

var fileset = new FileSet(
File.ReadAllLines(watchList)
isNetCoreApp31OrNewer,
lines.Skip(1)
.Select(l => l?.Trim())
.Where(l => !string.IsNullOrEmpty(l)));

Expand Down Expand Up @@ -123,7 +128,7 @@ public async Task<IFileSet> CreateAsync(CancellationToken cancellationToken)
{
_reporter.Warn("Fix the error to continue or press Ctrl+C to exit.");

var fileSet = new FileSet(new[] { _projectFile });
var fileSet = new FileSet(false, new[] { _projectFile });

using (var watcher = new FileSetWatcher(fileSet, _reporter))
{
Expand Down
46 changes: 29 additions & 17 deletions src/Tools/dotnet-watch/src/Internal/ProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,37 +37,49 @@ public async Task<int> RunAsync(ProcessSpec processSpec, CancellationToken cance
{
cancellationToken.Register(() => processState.TryKill());

process.OutputDataReceived += (_, a) =>
var readOutput = false;
var readError = false;
if (processSpec.IsOutputCaptured)
{
if (!string.IsNullOrEmpty(a.Data))
readOutput = true;
readError = true;
process.OutputDataReceived += (_, a) =>
{
processSpec.OutputCapture.AddLine(a.Data);
}
};
process.ErrorDataReceived += (_, a) =>
{
if (!string.IsNullOrEmpty(a.Data))
if (!string.IsNullOrEmpty(a.Data))
{
processSpec.OutputCapture.AddLine(a.Data);
}
};
process.ErrorDataReceived += (_, a) =>
{
processSpec.OutputCapture.AddLine(a.Data);
}
};
if (!string.IsNullOrEmpty(a.Data))
{
processSpec.OutputCapture.AddLine(a.Data);
}
};
}
else if (processSpec.OnOutput != null)
{
readOutput = true;
process.OutputDataReceived += processSpec.OnOutput;
}

stopwatch.Start();
process.Start();

_reporter.Verbose($"Started '{processSpec.Executable}' with process id {process.Id}");

if (processSpec.IsOutputCaptured)
if (readOutput)
{
process.BeginErrorReadLine();
process.BeginOutputReadLine();
await processState.Task;
}
else
if (readError)
{
await processState.Task;
process.BeginErrorReadLine();
}

await processState.Task;

exitCode = process.ExitCode;
stopwatch.Stop();
_reporter.Verbose($"Process id {process.Id} ran for {stopwatch.ElapsedMilliseconds}ms");
Expand All @@ -87,7 +99,7 @@ private Process CreateProcess(ProcessSpec processSpec)
Arguments = ArgumentEscaper.EscapeAndConcatenate(processSpec.Arguments),
UseShellExecute = false,
WorkingDirectory = processSpec.WorkingDirectory,
RedirectStandardOutput = processSpec.IsOutputCaptured,
RedirectStandardOutput = processSpec.IsOutputCaptured || (processSpec.OnOutput != null),
RedirectStandardError = processSpec.IsOutputCaptured,
}
};
Expand Down
Loading