Skip to content

Commit

Permalink
Add support for reloading static files (#27744)
Browse files Browse the repository at this point in the history
* Add support for reloading static files

* Update vanilla css files without a browser reload
* Refresh the browser when changes to other static content is detected.
  • Loading branch information
pranavkm authored Dec 9, 2020
1 parent 692bfd6 commit 7d73bda
Show file tree
Hide file tree
Showing 34 changed files with 582 additions and 162 deletions.
2 changes: 2 additions & 0 deletions src/Tools/Shared/TestHelpers/TemporaryCSharpProject.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// 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.

#nullable disable

using System;
using System.Collections.Generic;
using System.Diagnostics;
Expand Down
4 changes: 3 additions & 1 deletion src/Tools/Shared/TestHelpers/TemporaryDirectory.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// 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.

#nullable enable

using System;
using System.Collections.Generic;
using System.IO;
Expand All @@ -12,7 +14,7 @@ public class TemporaryDirectory : IDisposable
private List<TemporaryCSharpProject> _projects = new List<TemporaryCSharpProject>();
private List<TemporaryDirectory> _subdirs = new List<TemporaryDirectory>();
private Dictionary<string, string> _files = new Dictionary<string, string>();
private TemporaryDirectory _parent;
private TemporaryDirectory? _parent;

public TemporaryDirectory()
{
Expand Down
2 changes: 1 addition & 1 deletion src/Tools/Shared/TestHelpers/TestConsole.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace Microsoft.Extensions.Tools.Internal
{
public class TestConsole : IConsole
{
private event ConsoleCancelEventHandler _cancelKeyPress;
private event ConsoleCancelEventHandler _cancelKeyPress = default!;
private readonly TaskCompletionSource<bool> _cancelKeySubscribed = new TaskCompletionSource<bool>();
private readonly TestOutputWriter _testWriter;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,55 @@ setTimeout(function () {
return;
}
connection.onmessage = function (message) {
const updateStaticFileMessage = 'UpdateStaticFile||';

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;
const t = document.title;
const r = ['☱', '☲', '☴'];
let i = 0;
setInterval(function () { document.title = r[i++ % r.length] + ' ' + t; }, 240);
} else if (message.data.startsWith(updateStaticFileMessage)) {
const fileName = message.data.substring(updateStaticFileMessage.length);
if (!fileName.endsWith('.css')) {
console.debug(`File change detected to static content file ${fileName}. Reloading page...`);
location.reload();
return;
}

const styleElement = document.querySelector(`link[href^="${fileName}"]`) ||
document.querySelector(`link[href^="${document.baseURI}${fileName}"]`);
if (styleElement && styleElement.parentNode) {
if (styleElement.loading) {
// A file change notification may be triggered for the same file before the browser
// finishes processing a previous update. In this case, it's easiest to ignore later updates
return;
}

const newElement = styleElement.cloneNode();
const href = styleElement.href;
newElement.href = href.split('?', 1)[0] + `?nonce=${Date.now()}`;

styleElement.loading = true;
newElement.loading = true;
newElement.addEventListener('load', function () {
newElement.loading = false;
styleElement.remove();
});

styleElement.parentNode.insertBefore(newElement, styleElement.nextSibling);
} else {
console.debug('Unable to find a stylesheet to update. Reloading the page.');
location.reload();
}
} else {
console.debug('Unknown browser-refresh message received: ', message.data);
}
}

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.') }
Expand Down
2 changes: 2 additions & 0 deletions src/Tools/dotnet-watch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Some configuration options can be passed to `dotnet watch` through environment v
| DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM | By default, `dotnet watch` optimizes the build by avoiding certain operations such as running restore or re-evaluating the set of watched files on every file change. If set to "1" or "true", these optimizations are disabled. |
| DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER | `dotnet watch run` will attempt to launch browsers for web apps with `launchBrowser` configured in `launchSettings.json`. If set to "1" or "true", this behavior is suppressed. |
| DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM | `dotnet watch run` will attempt to refresh browsers when it detects file changes. If set to "1" or "true", this behavior is suppressed. This behavior is also suppressed if DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER is set. |
| DOTNET_WATCH_SUPPRESS_STATIC_FILE_HANDLING | If set to "1", or "true", `dotnet watch` will not perform special handling for static content file

### MSBuild

dotnet-watch can be configured from the MSBuild project file being watched.
Expand Down
9 changes: 8 additions & 1 deletion src/Tools/dotnet-watch/src/BrowserRefreshServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.Linq;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
Expand All @@ -20,6 +21,8 @@ namespace Microsoft.DotNet.Watcher.Tools
{
public class BrowserRefreshServer : IAsyncDisposable
{
private readonly byte[] ReloadMessage = Encoding.UTF8.GetBytes("Reload");
private readonly byte[] WaitMessage = Encoding.UTF8.GetBytes("Wait");
private readonly IReporter _reporter;
private readonly TaskCompletionSource _taskCompletionSource;
private IHost _refreshServer;
Expand Down Expand Up @@ -73,7 +76,7 @@ private async Task WebSocketRequest(HttpContext context)
await _taskCompletionSource.Task;
}

public async Task SendMessage(byte[] messageBytes, CancellationToken cancellationToken = default)
public async virtual ValueTask SendMessage(ReadOnlyMemory<byte> messageBytes, CancellationToken cancellationToken = default)
{
if (_webSocket == null || _webSocket.CloseStatus.HasValue)
{
Expand Down Expand Up @@ -105,5 +108,9 @@ public async ValueTask DisposeAsync()

_taskCompletionSource.TrySetResult();
}

public ValueTask ReloadAsync(CancellationToken cancellationToken) => SendMessage(ReloadMessage, cancellationToken);

public ValueTask SendWaitMessageAsync(CancellationToken cancellationToken) => SendMessage(WaitMessage, cancellationToken);
}
}
6 changes: 4 additions & 2 deletions src/Tools/dotnet-watch/src/DotNetWatchContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ public class DotNetWatchContext

public ProcessSpec ProcessSpec { get; set; }

public IFileSet FileSet { get; set; }
public FileSet FileSet { get; set; }

public int Iteration { get; set; }

public string ChangedFile { get; set; }
public FileItem? ChangedFile { get; set; }

public bool RequiresMSBuildRevaluation { get; set; }

public bool SuppressMSBuildIncrementalism { get; set; }

public BrowserRefreshServer BrowserRefreshServer { get; set; }
}
}
24 changes: 24 additions & 0 deletions src/Tools/dotnet-watch/src/DotNetWatchOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// 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;

namespace Microsoft.DotNet.Watcher
{
public record DotNetWatchOptions(
bool SuppressHandlingStaticContentFiles,
bool SuppressMSBuildIncrementalism)
{
public static DotNetWatchOptions Default { get; } = new DotNetWatchOptions
(
SuppressHandlingStaticContentFiles: GetSuppressedValue("DOTNET_WATCH_SUPPRESS_STATIC_FILE_HANDLING"),
SuppressMSBuildIncrementalism: GetSuppressedValue("DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM")
);

private static bool GetSuppressedValue(string key)
{
var envValue = Environment.GetEnvironmentVariable(key);
return envValue == "1" || envValue == "true";
}
}
}
43 changes: 32 additions & 11 deletions src/Tools/dotnet-watch/src/DotNetWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -18,14 +18,16 @@ public class DotNetWatcher : IAsyncDisposable
{
private readonly IReporter _reporter;
private readonly ProcessRunner _processRunner;
private readonly DotNetWatchOptions _dotnetWatchOptions;
private readonly IWatchFilter[] _filters;

public DotNetWatcher(IReporter reporter, IFileSetFactory fileSetFactory)
public DotNetWatcher(IReporter reporter, IFileSetFactory fileSetFactory, DotNetWatchOptions dotNetWatchOptions)
{
Ensure.NotNull(reporter, nameof(reporter));

_reporter = reporter;
_processRunner = new ProcessRunner(reporter);
_dotnetWatchOptions = dotNetWatchOptions;

_filters = new IWatchFilter[]
{
Expand All @@ -44,13 +46,12 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
cancelledTaskSource);

var initialArguments = processSpec.Arguments.ToArray();
var suppressMSBuildIncrementalism = Environment.GetEnvironmentVariable("DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM");
var context = new DotNetWatchContext
{
Iteration = -1,
ProcessSpec = processSpec,
Reporter = _reporter,
SuppressMSBuildIncrementalism = suppressMSBuildIncrementalism == "1" || suppressMSBuildIncrementalism == "true",
SuppressMSBuildIncrementalism = _dotnetWatchOptions.SuppressMSBuildIncrementalism,
};

if (context.SuppressMSBuildIncrementalism)
Expand Down Expand Up @@ -93,15 +94,34 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
currentRunCancellationSource.Token))
using (var fileSetWatcher = new FileSetWatcher(fileSet, _reporter))
{
var fileSetTask = fileSetWatcher.GetChangedFileAsync(combinedCancellationSource.Token);
var processTask = _processRunner.RunAsync(processSpec, combinedCancellationSource.Token);

var args = ArgumentEscaper.EscapeAndConcatenate(processSpec.Arguments);
_reporter.Verbose($"Running {processSpec.ShortDisplayName()} with the following arguments: {args}");

_reporter.Output("Started");

var finishedTask = await Task.WhenAny(processTask, fileSetTask, cancelledTaskSource.Task);
Task<FileItem?> fileSetTask;
Task finishedTask;

while (true)
{
fileSetTask = fileSetWatcher.GetChangedFileAsync(combinedCancellationSource.Token);
finishedTask = await Task.WhenAny(processTask, fileSetTask, cancelledTaskSource.Task);

if (context.BrowserRefreshServer is not null &&
finishedTask == fileSetTask &&
fileSetTask.Result is FileItem { FileKind: FileKind.StaticFile } file)
{
_reporter.Verbose($"Handling file change event for static content {file.FilePath}.");

// If we can handle the file change without a browser refresh, do it.
await StaticContentHandler.TryHandleFileAction(context.BrowserRefreshServer, file, combinedCancellationSource.Token);
}
else
{
break;
}
}

// Regardless of the which task finished first, make sure everything is cancelled
// and wait for dotnet to exit. We don't want orphan processes
Expand All @@ -125,18 +145,19 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
return;
}

context.ChangedFile = fileSetTask.Result;
if (finishedTask == processTask)
{
// Process exited. Redo evaludation
context.RequiresMSBuildRevaluation = true;
// Now wait for a file to change before restarting process
context.ChangedFile = await fileSetWatcher.GetChangedFileAsync(cancellationToken, () => _reporter.Warn("Waiting for a file to change before restarting dotnet..."));
}

if (!string.IsNullOrEmpty(fileSetTask.Result))
else
{
_reporter.Output($"File changed: {fileSetTask.Result}");
Debug.Assert(finishedTask == fileSetTask);
var changedFile = fileSetTask.Result;
context.ChangedFile = changedFile;
_reporter.Output($"File changed: {changedFile.Value.FilePath}");
}
}
}
Expand Down
21 changes: 21 additions & 0 deletions src/Tools/dotnet-watch/src/FileItem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// 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.

namespace Microsoft.DotNet.Watcher
{
public readonly struct FileItem
{
public FileItem(string filePath, FileKind fileKind = FileKind.Default, string staticWebAssetPath = null)
{
FilePath = filePath;
FileKind = fileKind;
StaticWebAssetPath = staticWebAssetPath;
}

public string FilePath { get; }

public FileKind FileKind { get; }

public string StaticWebAssetPath { get; }
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
// 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;

namespace Microsoft.DotNet.Watcher
{
public interface IFileSet : IEnumerable<string>
public enum FileKind
{
bool IsNetCoreApp31OrNewer { get; }

bool Contains(string filePath);
Default,
StaticFile,
}
}
36 changes: 36 additions & 0 deletions src/Tools/dotnet-watch/src/FileSet.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// 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;
using System.Collections.Generic;

namespace Microsoft.DotNet.Watcher
{
public class FileSet : IEnumerable<FileItem>
{
private readonly Dictionary<string, FileItem> _files;

public FileSet(bool isNetCoreApp31OrNewer, IEnumerable<FileItem> files)
{
IsNetCoreApp31OrNewer = isNetCoreApp31OrNewer;
_files = new Dictionary<string, FileItem>(StringComparer.Ordinal);
foreach (var item in files)
{
_files[item.FilePath] = item;
}
}

public bool TryGetValue(string filePath, out FileItem fileItem) => _files.TryGetValue(filePath, out fileItem);

public int Count => _files.Count;

public bool IsNetCoreApp31OrNewer { get; }

public static readonly FileSet Empty = new FileSet(false, Array.Empty<FileItem>());

public IEnumerator<FileItem> GetEnumerator() => _files.Values.GetEnumerator();

IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}
7 changes: 4 additions & 3 deletions src/Tools/dotnet-watch/src/IFileSetFactory.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
// 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.Threading;
using System.Threading.Tasks;
using Microsoft.DotNet.Watcher.Internal;

namespace Microsoft.DotNet.Watcher
{
public interface IFileSetFactory
{
Task<IFileSet> CreateAsync(CancellationToken cancellationToken);
Task<FileSet> CreateAsync(CancellationToken cancellationToken);
}
}
}
Loading

0 comments on commit 7d73bda

Please sign in to comment.