Skip to content

Commit c81e0c6

Browse files
committed
demo 11 - hmr
1 parent 18b3a1b commit c81e0c6

29 files changed

+12810
-3
lines changed

.vscode/launch.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
"request": "launch",
1111
"preLaunchTask": "build",
1212
// If you have changed target frameworks, make sure to update the program path.
13-
"program": "${workspaceFolder}/src/demo9/bin/Debug/netcoreapp2.1/demo9.dll",
13+
"program": "${workspaceFolder}/src/demo11/bin/Debug/netcoreapp2.1/demo11.dll",
1414
"args": [],
15-
"cwd": "${workspaceFolder}/src/demo9",
15+
"cwd": "${workspaceFolder}/src/demo11",
1616
"stopAtEntry": false,
1717
"internalConsoleOptions": "openOnSessionStart",
1818
"launchBrowser": {

.vscode/tasks.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"type": "process",
88
"args": [
99
"build",
10-
"${workspaceFolder}/src/demo9/demo9.csproj"
10+
"${workspaceFolder}/src/demo11/demo11.csproj"
1111
],
1212
"problemMatcher": "$msCompile"
1313
}

README.MD

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ The master branch is currently using .NET Core 2.1, ASP.NET Core 2.1, Vue.js 2.5
3333

3434
- [Demo 10](src/demo10) - Same as demo 9, but built using the .NET CLI and the Vue.js CLI.
3535

36+
- [Demo 11](src/demo11) - **Ignore this. Nothing to see here!** Hot module replacement using Vue CLI and Microsoft.AspNetCore.SpaServices. Borrowed some code from [JavaScriptServices](https://github.com/aspnet/JavaScriptServices/tree/2.1.0/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli)
37+
3638
## Useful tools
3739

3840
- [Visual Studio Code](https://code.visualstudio.com/?WT.mc_id=code-github-cephilli)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using System;
2+
using System.IO;
3+
using System.Net;
4+
using System.Net.Sockets;
5+
using System.Text;
6+
using System.Text.RegularExpressions;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
10+
namespace demo11.Extensions
11+
{
12+
public static class TaskTimeoutExtensions
13+
{
14+
public static async Task WithTimeout(this Task task, TimeSpan timeoutDelay, string message)
15+
{
16+
if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay)))
17+
{
18+
task.Wait(); // Allow any errors to propagate
19+
}
20+
else
21+
{
22+
throw new TimeoutException(message);
23+
}
24+
}
25+
26+
public static async Task<T> WithTimeout<T>(this Task<T> task, TimeSpan timeoutDelay, string message)
27+
{
28+
if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay)))
29+
{
30+
return task.Result;
31+
}
32+
else
33+
{
34+
throw new TimeoutException(message);
35+
}
36+
}
37+
}
38+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Diagnostics;
4+
using System.Runtime.InteropServices;
5+
using System.Text.RegularExpressions;
6+
using Microsoft.Extensions.Logging;
7+
8+
namespace demo11.Extensions
9+
{
10+
public class NpmScriptRunner
11+
{
12+
private static Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1));
13+
private Process _process;
14+
15+
public NpmScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary<string, string> envVars)
16+
{
17+
if (string.IsNullOrEmpty(workingDirectory))
18+
{
19+
throw new ArgumentException("Cannot be null or empty.", nameof(workingDirectory));
20+
}
21+
22+
if (string.IsNullOrEmpty(scriptName))
23+
{
24+
throw new ArgumentException("Cannot be null or empty.", nameof(scriptName));
25+
}
26+
27+
var npmExe = "npm";
28+
var completeArguments = $"run {scriptName} -- {arguments ?? string.Empty}";
29+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
30+
{
31+
// On Windows, the NPM executable is a .cmd file, so it can't be executed
32+
// directly (except with UseShellExecute=true, but that's no good, because
33+
// it prevents capturing stdio). So we need to invoke it via "cmd /c".
34+
npmExe = "cmd";
35+
completeArguments = $"/c npm {completeArguments}";
36+
}
37+
38+
var processStartInfo = new ProcessStartInfo(npmExe)
39+
{
40+
Arguments = completeArguments,
41+
UseShellExecute = false,
42+
RedirectStandardInput = true,
43+
RedirectStandardOutput = true,
44+
RedirectStandardError = true,
45+
WorkingDirectory = workingDirectory
46+
};
47+
48+
if (envVars != null)
49+
{
50+
foreach (var keyValuePair in envVars)
51+
{
52+
processStartInfo.Environment[keyValuePair.Key] = keyValuePair.Value;
53+
}
54+
}
55+
56+
_process = LaunchNodeProcess(processStartInfo);
57+
}
58+
59+
public void AttachToLogger(ILogger logger)
60+
{
61+
// // When the NPM task emits complete lines, pass them through to the real logger
62+
// StdOut.OnReceivedLine += line =>
63+
// {
64+
// if (!string.IsNullOrWhiteSpace(line))
65+
// {
66+
// // NPM tasks commonly emit ANSI colors, but it wouldn't make sense to forward
67+
// // those to loggers (because a logger isn't necessarily any kind of terminal)
68+
// logger.LogInformation(StripAnsiColors(line));
69+
// }
70+
// };
71+
72+
// StdErr.OnReceivedLine += line =>
73+
// {
74+
// if (!string.IsNullOrWhiteSpace(line))
75+
// {
76+
// logger.LogError(StripAnsiColors(line));
77+
// }
78+
// };
79+
80+
// // But when it emits incomplete lines, assume this is progress information and
81+
// // hence just pass it through to StdOut regardless of logger config.
82+
// StdErr.OnReceivedChunk += chunk =>
83+
// {
84+
// var containsNewline = Array.IndexOf(
85+
// chunk.Array, '\n', chunk.Offset, chunk.Count) >= 0;
86+
// if (!containsNewline)
87+
// {
88+
// Console.Write(chunk.Array, chunk.Offset, chunk.Count);
89+
// }
90+
// };
91+
}
92+
93+
private static string StripAnsiColors(string line)
94+
=> AnsiColorRegex.Replace(line, string.Empty);
95+
96+
private static Process LaunchNodeProcess(ProcessStartInfo startInfo)
97+
{
98+
try
99+
{
100+
var process = Process.Start(startInfo);
101+
102+
// See equivalent comment in OutOfProcessNodeInstance.cs for why
103+
process.EnableRaisingEvents = true;
104+
105+
return process;
106+
}
107+
catch (Exception ex)
108+
{
109+
var message = $"Failed to start 'npm'. To resolve this:.\n\n"
110+
+ "[1] Ensure that 'npm' is installed and can be found in one of the PATH directories.\n"
111+
+ $" Current PATH enviroment variable is: { Environment.GetEnvironmentVariable("PATH") }\n"
112+
+ " Make sure the executable is in one of those directories, or update your PATH.\n\n"
113+
+ "[2] See the InnerException for further details of the cause.";
114+
throw new InvalidOperationException(message, ex);
115+
}
116+
}
117+
}
118+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
using System;
2+
using System.IO;
3+
using System.Net.Http;
4+
using System.Text.RegularExpressions;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.AspNetCore.Builder;
8+
using Microsoft.AspNetCore.SpaServices;
9+
using Microsoft.Extensions.Logging;
10+
using Microsoft.Extensions.DependencyInjection;
11+
using Microsoft.Extensions.Logging.Console;
12+
using System.Net.Sockets;
13+
using System.Net;
14+
15+
namespace demo11.Extensions
16+
{
17+
public static class VueCliMiddleware
18+
{
19+
private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices";
20+
private static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(10);
21+
22+
public static void Attach(ISpaBuilder spaBuilder, string npmScriptName)
23+
{
24+
var sourcePath = spaBuilder.Options.SourcePath;
25+
if (string.IsNullOrEmpty(sourcePath))
26+
{
27+
throw new ArgumentException("Cannot be null or empty", nameof(sourcePath));
28+
}
29+
30+
if (string.IsNullOrEmpty(npmScriptName))
31+
{
32+
throw new ArgumentException("Cannot be null or empty", nameof(npmScriptName));
33+
}
34+
35+
// Start Vue CLI and attach to middleware pipeline
36+
var appBuilder = spaBuilder.ApplicationBuilder;
37+
var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName);
38+
var vueCliServerInfoTask = StartVueCliServerAsync(sourcePath, npmScriptName, logger);
39+
40+
SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, () =>
41+
{
42+
// On each request, we create a separate startup task with its own timeout. That way, even if
43+
// the first request times out, subsequent requests could still work.
44+
var timeout = spaBuilder.Options.StartupTimeout;
45+
return vueCliServerInfoTask.WithTimeout(timeout,
46+
$"The Vue CLI process did not start listening for requests " +
47+
$"within the timeout period of {timeout.Seconds} seconds. " +
48+
$"Check the log output for error information.");
49+
});
50+
}
51+
52+
private static async Task<Uri> StartVueCliServerAsync(string sourcePath, string npmScriptName, ILogger logger)
53+
{
54+
var portNumber = FindAvailablePort();
55+
logger.LogInformation("Starting the Vue CLI");
56+
57+
var npmScriptRunner = new NpmScriptRunner(sourcePath, npmScriptName, string.Empty, null);
58+
npmScriptRunner.AttachToLogger(logger);
59+
60+
var uri = new Uri("http://localhost:8080");
61+
62+
await WaitForAngularCliServerToAcceptRequests(uri);
63+
64+
return uri;
65+
}
66+
67+
private static async Task WaitForAngularCliServerToAcceptRequests(Uri cliServerUri)
68+
{
69+
var timeoutMilliseconds = 1000;
70+
var client = new HttpClient();
71+
72+
while (true)
73+
{
74+
try
75+
{
76+
// If we get any HTTP response, the CLI server is ready
77+
await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, cliServerUri), new CancellationTokenSource(timeoutMilliseconds).Token);
78+
break;
79+
}
80+
catch (Exception)
81+
{
82+
await Task.Delay(500);
83+
84+
// Depending on the host's networking configuration, the requests can take a while
85+
// to go through, most likely due to the time spent resolving 'localhost'.
86+
// Each time we have a failure, allow a bit longer next time (up to a maximum).
87+
// This only influences the time until we regard the dev server as 'ready', so it
88+
// doesn't affect the runtime perf (even in dev mode) once the first connection is made.
89+
// Resolves https://github.com/aspnet/JavaScriptServices/issues/1611
90+
if (timeoutMilliseconds < 10000)
91+
{
92+
timeoutMilliseconds += 3000;
93+
}
94+
}
95+
}
96+
}
97+
98+
private static int FindAvailablePort()
99+
{
100+
var listener = new TcpListener(IPAddress.Loopback, 0);
101+
listener.Start();
102+
try
103+
{
104+
return ((IPEndPoint)listener.LocalEndpoint).Port;
105+
}
106+
finally
107+
{
108+
listener.Stop();
109+
}
110+
}
111+
}
112+
113+
public static class LoggerFinder
114+
{
115+
public static ILogger GetOrCreateLogger(IApplicationBuilder appBuilder, string logCategoryName)
116+
{
117+
// If the DI system gives us a logger, use it. Otherwise, set up a default one.
118+
var loggerFactory = appBuilder.ApplicationServices.GetService<ILoggerFactory>();
119+
var logger = loggerFactory != null ? loggerFactory.CreateLogger(logCategoryName) : new ConsoleLogger(logCategoryName, null, false);
120+
return logger;
121+
}
122+
}
123+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.AspNetCore.SpaServices;
3+
using System;
4+
5+
namespace demo11.Extensions
6+
{
7+
public static class VueCliMiddlewareExtensions
8+
{
9+
public static void UseVueCliServer(
10+
this ISpaBuilder spaBuilder,
11+
string npmScript)
12+
{
13+
if (spaBuilder == null)
14+
{
15+
throw new ArgumentNullException(nameof(spaBuilder));
16+
}
17+
18+
var spaOptions = spaBuilder.Options;
19+
20+
if (string.IsNullOrEmpty(spaOptions.SourcePath))
21+
{
22+
throw new InvalidOperationException($"To use {nameof(UseVueCliServer)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}.");
23+
}
24+
25+
VueCliMiddleware.Attach(spaBuilder, npmScript);
26+
}
27+
}
28+
}

src/demo11/Program.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Threading.Tasks;
6+
using Microsoft.AspNetCore;
7+
using Microsoft.AspNetCore.Hosting;
8+
using Microsoft.Extensions.Configuration;
9+
using Microsoft.Extensions.Logging;
10+
11+
namespace demo11
12+
{
13+
public class Program
14+
{
15+
public static void Main(string[] args)
16+
{
17+
CreateWebHostBuilder(args).Build().Run();
18+
}
19+
20+
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
21+
WebHost.CreateDefaultBuilder(args)
22+
.UseStartup<Startup>();
23+
}
24+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"iisSettings": {
3+
"windowsAuthentication": false,
4+
"anonymousAuthentication": true,
5+
"iisExpress": {
6+
"applicationUrl": "http://localhost:54056",
7+
"sslPort": 44327
8+
}
9+
},
10+
"profiles": {
11+
"IIS Express": {
12+
"commandName": "IISExpress",
13+
"launchBrowser": true,
14+
"environmentVariables": {
15+
"ASPNETCORE_ENVIRONMENT": "Development"
16+
}
17+
},
18+
"demo11": {
19+
"commandName": "Project",
20+
"launchBrowser": true,
21+
"applicationUrl": "https://localhost:5001;http://localhost:5000",
22+
"environmentVariables": {
23+
"ASPNETCORE_ENVIRONMENT": "Development"
24+
}
25+
}
26+
}
27+
}

0 commit comments

Comments
 (0)