Skip to content

Commit

Permalink
Auto rebuild when reloading after a file change.
Browse files Browse the repository at this point in the history
  • Loading branch information
SteveSandersonMS committed Apr 11, 2018
1 parent 7252443 commit 4d93f6e
Show file tree
Hide file tree
Showing 14 changed files with 758 additions and 0 deletions.
3 changes: 3 additions & 0 deletions src/Microsoft.AspNetCore.Blazor.Build/targets/All.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
<PropertyGroup>
<DefaultWebContentItemExcludes>$(DefaultWebContentItemExcludes);wwwroot\**</DefaultWebContentItemExcludes>

<!-- By default, enable auto rebuilds for debug builds. Note that the server will not enable it in production environments regardless. -->
<BlazorRebuildOnFileChange Condition="'$(Configuration)' == 'Debug'">true</BlazorRebuildOnFileChange>

<!-- We can remove this after updating to newer Razor tooling, where it's enabled by default -->
<UseRazorBuildServer>true</UseRazorBuildServer>
</PropertyGroup>
Expand Down
1 change: 1 addition & 0 deletions src/Microsoft.AspNetCore.Blazor.Build/targets/All.targets
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
</PropertyGroup>
<WriteLinesToFile File="$(BlazorMetadataFilePath)" Lines="$(MSBuildProjectFullPath)" Overwrite="true" Encoding="Unicode"/>
<WriteLinesToFile File="$(BlazorMetadataFilePath)" Lines="$(OutDir)$(AssemblyName).dll" Overwrite="false" Encoding="Unicode"/>
<WriteLinesToFile File="$(BlazorMetadataFilePath)" Condition="'$(BlazorRebuildOnFileChange)'=='true'" Lines="autorebuild:true" Overwrite="false" Encoding="Unicode"/>
<ItemGroup>
<ContentWithTargetPath Include="$(BlazorMetadataFilePath)" TargetPath="$(BlazorMetadataFileName)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// 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.Blazor.Server;
using Microsoft.AspNetCore.Blazor.Server.AutoRebuild;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;

namespace Microsoft.AspNetCore.Builder
{
internal static class AutoRebuildExtensions
{
// Note that we don't need to watch typical static-file extensions (.css, .js, etc.)
// because anything in wwwroot is just served directly from disk on each reload. But
// as a special case, we do watch index.html because it needs compilation.
// TODO: Make the set of extensions and exclusions configurable in csproj
private static string[] _includedSuffixes = new[] { ".cs", ".cshtml", "index.html" };
private static string[] _excludedDirectories = new[] { "obj", "bin" };

public static void UseAutoRebuild(this IApplicationBuilder appBuilder, BlazorConfig config)
{
// Currently this only supports VS for Windows. Later on we can add
// an IRebuildService implementation for VS for Mac, etc.
if (!VSForWindowsRebuildService.TryCreate(out var rebuildService))
{
return; // You're not on Windows, or you didn't launch this process from VS
}

// Assume we're up to date when the app starts.
var buildToken = new RebuildToken(new DateTime(1970, 1, 1)) { BuildTask = Task.CompletedTask, };

WatchFileSystem(config, () =>
{
// Don't start the recompilation immediately. We only start it when the next
// HTTP request arrives, because it's annoying if the IDE is constantly rebuilding
// when you're making changes to multiple files and aren't ready to reload
// in the browser yet.
//
// Replacing the token means that new requests that come in will trigger a rebuild,
// and will all 'join' that build until a new file change occurs.
buildToken = new RebuildToken(DateTime.Now);
});

appBuilder.Use(async (context, next) =>
{
try
{
var token = buildToken;
if (token.BuildTask == null)
{
// The build is out of date, but a new build is not yet started.
//
// We can count on VS to only allow one build at a time, this is a safe race
// because if we request a second concurrent build, it will 'join' the current one.
var task = rebuildService.PerformRebuildAsync(
config.SourceMSBuildPath,
token.LastChange);
token.BuildTask = task;
}

// In the general case it's safe to await this task, it will be a completed task
// if everything is up to date.
await token.BuildTask;
}
catch (Exception)
{
// If there's no listener on the other end of the pipe, or if anything
// else goes wrong, we just let the incoming request continue.
// There's nowhere useful to log this information so if people report
// problems we'll just have to get a repro and debug it.
// If it was an error on the VS side, it logs to the output window.
}

await next();
});
}

private static void WatchFileSystem(BlazorConfig config, Action onWrite)
{
var clientAppRootDir = Path.GetDirectoryName(config.SourceMSBuildPath);
var excludePathPrefixes = _excludedDirectories.Select(subdir
=> Path.Combine(clientAppRootDir, subdir) + Path.DirectorySeparatorChar);

var fsw = new FileSystemWatcher(clientAppRootDir);
fsw.Created += OnEvent;
fsw.Changed += OnEvent;
fsw.Deleted += OnEvent;
fsw.Renamed += OnEvent;
fsw.IncludeSubdirectories = true;
fsw.EnableRaisingEvents = true;

void OnEvent(object sender, FileSystemEventArgs eventArgs)
{
if (!File.Exists(eventArgs.FullPath))
{
// It's probably a directory rather than a file
return;
}

if (!_includedSuffixes.Any(ext => eventArgs.Name.EndsWith(ext, StringComparison.OrdinalIgnoreCase)))
{
// Not a candiate file type
return;
}

if (excludePathPrefixes.Any(prefix => eventArgs.FullPath.StartsWith(prefix, StringComparison.Ordinal)))
{
// In an excluded subdirectory
return;
}

onWrite();
}
}

// Represents a three-state value for the state of the build
//
// BuildTask == null means the build is out of date, but no build has started
// BuildTask.IsCompleted == false means the build has been started, but has not completed
// BuildTask.IsCompleted == true means the build has completed
private class RebuildToken
{
public RebuildToken(DateTime lastChange)
{
LastChange = lastChange;
}

public DateTime LastChange { get; }

public Task BuildTask;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// 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.Threading.Tasks;

namespace Microsoft.AspNetCore.Blazor.Server.AutoRebuild
{
/// <summary>
/// Represents a mechanism for rebuilding a .NET project. For example, it
/// could be a way of signalling to a VS process to perform a build.
/// </summary>
internal interface IRebuildService
{
Task<bool> PerformRebuildAsync(string projectFullPath, DateTime ifNotBuiltSince);
}
}
51 changes: 51 additions & 0 deletions src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/ProcessUtils.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// 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.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;

namespace Microsoft.AspNetCore.Blazor.Server.AutoRebuild
{
internal static class ProcessUtils
{
// Based on https://stackoverflow.com/a/3346055

public static Process GetParent(Process process)
{
var result = new ProcessBasicInformation();
var handle = process.Handle;
var status = NtQueryInformationProcess(handle, 0, ref result, Marshal.SizeOf(result), out var returnLength);
if (status != 0)
{
throw new Win32Exception(status);
}

try
{
var parentProcessId = result.InheritedFromUniqueProcessId.ToInt32();
return parentProcessId > 0 ? Process.GetProcessById(parentProcessId) : null;
}
catch (ArgumentException)
{
return null; // Process not found
}
}

[DllImport("ntdll.dll")]
private static extern int NtQueryInformationProcess(IntPtr processHandle, int processInformationClass, ref ProcessBasicInformation processInformation, int processInformationLength, out int returnLength);

[StructLayout(LayoutKind.Sequential)]
struct ProcessBasicInformation
{
// These members must match PROCESS_BASIC_INFORMATION
public IntPtr Reserved1;
public IntPtr PebBaseAddress;
public IntPtr Reserved2_0;
public IntPtr Reserved2_1;
public IntPtr UniqueProcessId;
public IntPtr InheritedFromUniqueProcessId;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// 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;

namespace Microsoft.AspNetCore.Blazor.Server.AutoRebuild
{
internal static class StreamProtocolExtensions
{
public static async Task WriteStringAsync(this Stream stream, string str)
{
var utf8Bytes = Encoding.UTF8.GetBytes(str);
await stream.WriteAsync(BitConverter.GetBytes(utf8Bytes.Length), 0, 4);
await stream.WriteAsync(utf8Bytes, 0, utf8Bytes.Length);
}

public static async Task WriteDateTimeAsync(this Stream stream, DateTime value)
{
var ticksBytes = BitConverter.GetBytes(value.Ticks);
await stream.WriteAsync(ticksBytes, 0, 8);
}

public static async Task<bool> ReadBoolAsync(this Stream stream)
{
var responseBuf = new byte[1];
await stream.ReadAsync(responseBuf, 0, 1);
return responseBuf[0] == 1;
}

public static async Task<int> ReadIntAsync(this Stream stream)
{
var responseBuf = new byte[4];
await stream.ReadAsync(responseBuf, 0, 4);
return BitConverter.ToInt32(responseBuf, 0);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// 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.Diagnostics;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Threading.Tasks;

namespace Microsoft.AspNetCore.Blazor.Server.AutoRebuild
{
/// <summary>
/// Finds the VS process that launched this app process (if any), and uses
/// named pipes to communicate with its AutoRebuild listener (if any).
/// </summary>
internal class VSForWindowsRebuildService : IRebuildService
{
private const int _connectionTimeoutMilliseconds = 3000;
private readonly Process _vsProcess;

public static bool TryCreate(out VSForWindowsRebuildService result)
{
var vsProcess = FindAncestorVSProcess();
if (vsProcess != null)
{
result = new VSForWindowsRebuildService(vsProcess);
return true;
}
else
{
result = null;
return false;
}
}

public async Task<bool> PerformRebuildAsync(string projectFullPath, DateTime ifNotBuiltSince)
{
var pipeName = $"BlazorAutoRebuild\\{_vsProcess.Id}";
using (var pipeClient = new NamedPipeClientStream(pipeName))
{
await pipeClient.ConnectAsync(_connectionTimeoutMilliseconds);

// Protocol:
// 1. Receive protocol version number from the VS listener
// If we're incompatible with it, send back special string "abort" and end
// 2. Send the project path to the VS listener
// 3. Send the 'if not rebuilt since' timestamp to the VS listener
// 4. Wait for it to send back a bool representing the result
// Keep in sync with AutoRebuildService.cs in the BlazorExtension project
// In the future we may extend this to getting back build error details
var remoteProtocolVersion = await pipeClient.ReadIntAsync();
if (remoteProtocolVersion == 1)
{
await pipeClient.WriteStringAsync(projectFullPath);
await pipeClient.WriteDateTimeAsync(ifNotBuiltSince);
return await pipeClient.ReadBoolAsync();
}
else
{
await pipeClient.WriteStringAsync("abort");
return false;
}
}
}

private VSForWindowsRebuildService(Process vsProcess)
{
_vsProcess = vsProcess ?? throw new ArgumentNullException(nameof(vsProcess));
}

private static Process FindAncestorVSProcess()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return null;
}

var candidateProcess = Process.GetCurrentProcess();
while (candidateProcess != null && !candidateProcess.HasExited)
{
// It's unlikely that anyone's going to have a non-VS process in the process
// hierarchy called 'devenv', but if that turns out to be a scenario, we could
// (for example) write the VS PID to the obj directory during build, and then
// only consider processes with that ID. We still want to be sure there really
// is such a process in our ancestor chain, otherwise if you did "dotnet run"
// in a command prompt, we'd be confused and think it was launched from VS.
if (candidateProcess.ProcessName.Equals("devenv", StringComparison.OrdinalIgnoreCase))
{
return candidateProcess;
}

try
{
candidateProcess = ProcessUtils.GetParent(candidateProcess);
}
catch (Exception)
{
// There's probably some permissions issue that prevents us from seeing
// further up the ancestor list, so we have to stop looking here.
break;
}
}

return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ public static void UseBlazor(
OnPrepareResponse = SetCacheHeaders
};

if (env.IsDevelopment() && config.EnableAutoRebuilding)
{
applicationBuilder.UseAutoRebuild(config);
}

// First, match the request against files in the client app dist directory
applicationBuilder.UseStaticFiles(distDirStaticFiles);

Expand Down
Loading

0 comments on commit 4d93f6e

Please sign in to comment.