Skip to content
This repository was archived by the owner on Apr 8, 2020. It is now read-only.

Commit 96d7f85

Browse files
Add UseReactDevelopmentServer() middleware. Factor out common code.
1 parent 30333e2 commit 96d7f85

9 files changed

+218
-41
lines changed

src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs

+6-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.AspNetCore.NodeServices.Npm;
66
using Microsoft.AspNetCore.NodeServices.Util;
77
using Microsoft.AspNetCore.SpaServices.Prerendering;
8+
using Microsoft.AspNetCore.SpaServices.Util;
89
using System;
910
using System.IO;
1011
using System.Text.RegularExpressions;
@@ -46,11 +47,14 @@ public Task Build(ISpaBuilder spaBuilder)
4647
throw new InvalidOperationException($"To use {nameof(AngularCliBuilder)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}.");
4748
}
4849

49-
var logger = AngularCliMiddleware.GetOrCreateLogger(spaBuilder.ApplicationBuilder);
50+
var logger = LoggerFinder.GetOrCreateLogger(
51+
spaBuilder.ApplicationBuilder,
52+
nameof(AngularCliBuilder));
5053
var npmScriptRunner = new NpmScriptRunner(
5154
sourcePath,
5255
_npmScriptName,
53-
"--watch");
56+
"--watch",
57+
null);
5458
npmScriptRunner.AttachToLogger(logger);
5559

5660
using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr))

src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs

+9-36
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,14 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using Microsoft.AspNetCore.Builder;
5-
using System;
6-
using System.Threading.Tasks;
7-
using Microsoft.AspNetCore.NodeServices.Npm;
8-
using System.Text.RegularExpressions;
95
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-
using System.IO;
6+
using Microsoft.AspNetCore.NodeServices.Npm;
157
using Microsoft.AspNetCore.NodeServices.Util;
8+
using Microsoft.AspNetCore.SpaServices.Util;
9+
using System;
10+
using System.IO;
11+
using System.Text.RegularExpressions;
12+
using System.Threading.Tasks;
1613

1714
namespace Microsoft.AspNetCore.SpaServices.AngularCli
1815
{
@@ -39,7 +36,7 @@ public static void Attach(
3936

4037
// Start Angular CLI and attach to middleware pipeline
4138
var appBuilder = spaBuilder.ApplicationBuilder;
42-
var logger = GetOrCreateLogger(appBuilder);
39+
var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName);
4340
var angularCliServerInfoTask = StartAngularCliServerAsync(sourcePath, npmScriptName, logger);
4441

4542
// Everything we proxy is hardcoded to target http://localhost because:
@@ -53,24 +50,14 @@ public static void Attach(
5350
SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, targetUriTask);
5451
}
5552

56-
internal static ILogger GetOrCreateLogger(IApplicationBuilder appBuilder)
57-
{
58-
// If the DI system gives us a logger, use it. Otherwise, set up a default one.
59-
var loggerFactory = appBuilder.ApplicationServices.GetService<ILoggerFactory>();
60-
var logger = loggerFactory != null
61-
? loggerFactory.CreateLogger(LogCategoryName)
62-
: new ConsoleLogger(LogCategoryName, null, false);
63-
return logger;
64-
}
65-
6653
private static async Task<AngularCliServerInfo> StartAngularCliServerAsync(
6754
string sourcePath, string npmScriptName, ILogger logger)
6855
{
69-
var portNumber = FindAvailablePort();
56+
var portNumber = TcpPortFinder.FindAvailablePort();
7057
logger.LogInformation($"Starting @angular/cli on port {portNumber}...");
7158

7259
var npmScriptRunner = new NpmScriptRunner(
73-
sourcePath, npmScriptName, $"--port {portNumber}");
60+
sourcePath, npmScriptName, $"--port {portNumber}", null);
7461
npmScriptRunner.AttachToLogger(logger);
7562

7663
Match openBrowserLine;
@@ -109,20 +96,6 @@ private static async Task<AngularCliServerInfo> StartAngularCliServerAsync(
10996
return serverInfo;
11097
}
11198

112-
private static int FindAvailablePort()
113-
{
114-
var listener = new TcpListener(IPAddress.Loopback, 0);
115-
listener.Start();
116-
try
117-
{
118-
return ((IPEndPoint)listener.LocalEndpoint).Port;
119-
}
120-
finally
121-
{
122-
listener.Stop();
123-
}
124-
}
125-
12699
class AngularCliServerInfo
127100
{
128101
public int Port { get; set; }

src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs

+13-3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Diagnostics;
88
using System.Runtime.InteropServices;
99
using System.Text.RegularExpressions;
10+
using System.Collections.Generic;
1011

1112
// This is under the NodeServices namespace because post 2.1 it will be moved to that package
1213
namespace Microsoft.AspNetCore.NodeServices.Npm
@@ -22,7 +23,7 @@ internal class NpmScriptRunner
2223

2324
private static Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1));
2425

25-
public NpmScriptRunner(string workingDirectory, string scriptName, string arguments)
26+
public NpmScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary<string, string> envVars)
2627
{
2728
if (string.IsNullOrEmpty(workingDirectory))
2829
{
@@ -45,16 +46,25 @@ public NpmScriptRunner(string workingDirectory, string scriptName, string argume
4546
completeArguments = $"/c npm {completeArguments}";
4647
}
4748

48-
var process = LaunchNodeProcess(new ProcessStartInfo(npmExe)
49+
var processStartInfo = new ProcessStartInfo(npmExe)
4950
{
5051
Arguments = completeArguments,
5152
UseShellExecute = false,
5253
RedirectStandardInput = true,
5354
RedirectStandardOutput = true,
5455
RedirectStandardError = true,
5556
WorkingDirectory = workingDirectory
56-
});
57+
};
58+
59+
if (envVars != null)
60+
{
61+
foreach (var keyValuePair in envVars)
62+
{
63+
processStartInfo.Environment[keyValuePair.Key] = keyValuePair.Value;
64+
}
65+
}
5766

67+
var process = LaunchNodeProcess(processStartInfo);
5868
StdOut = new EventedStreamReader(process.StandardOutput);
5969
StdErr = new EventedStreamReader(process.StandardError);
6070
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using Microsoft.AspNetCore.Builder;
5+
using Microsoft.Extensions.Logging;
6+
using Microsoft.AspNetCore.NodeServices.Npm;
7+
using Microsoft.AspNetCore.NodeServices.Util;
8+
using Microsoft.AspNetCore.SpaServices.Util;
9+
using System;
10+
using System.IO;
11+
using System.Collections.Generic;
12+
using System.Text.RegularExpressions;
13+
using System.Threading.Tasks;
14+
15+
namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
16+
{
17+
internal static class ReactDevelopmentServerMiddleware
18+
{
19+
private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices";
20+
private static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine
21+
private static TimeSpan StartupTimeout = TimeSpan.FromSeconds(50); // Note that the HTTP request itself by default times out after 60s, so you only get useful error information if this is shorter
22+
23+
public static void Attach(
24+
ISpaBuilder spaBuilder,
25+
string npmScriptName)
26+
{
27+
var sourcePath = spaBuilder.Options.SourcePath;
28+
if (string.IsNullOrEmpty(sourcePath))
29+
{
30+
throw new ArgumentException("Cannot be null or empty", nameof(sourcePath));
31+
}
32+
33+
if (string.IsNullOrEmpty(npmScriptName))
34+
{
35+
throw new ArgumentException("Cannot be null or empty", nameof(npmScriptName));
36+
}
37+
38+
// Start create-react-app and attach to middleware pipeline
39+
var appBuilder = spaBuilder.ApplicationBuilder;
40+
var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName);
41+
var portTask = StartCreateReactAppServerAsync(sourcePath, npmScriptName, logger);
42+
43+
// Everything we proxy is hardcoded to target http://localhost because:
44+
// - the requests are always from the local machine (we're not accepting remote
45+
// requests that go directly to the create-react-app server)
46+
// - given that, there's no reason to use https, and we couldn't even if we
47+
// wanted to, because in general the create-react-app server has no certificate
48+
var targetUriTask = portTask.ContinueWith(
49+
task => new UriBuilder("http", "localhost", task.Result).Uri);
50+
51+
SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, targetUriTask);
52+
}
53+
54+
private static async Task<int> StartCreateReactAppServerAsync(
55+
string sourcePath, string npmScriptName, ILogger logger)
56+
{
57+
var portNumber = TcpPortFinder.FindAvailablePort();
58+
logger.LogInformation($"Starting create-react-app server on port {portNumber}...");
59+
60+
var envVars = new Dictionary<string, string>
61+
{
62+
{ "PORT", portNumber.ToString() }
63+
};
64+
var npmScriptRunner = new NpmScriptRunner(
65+
sourcePath, npmScriptName, null, envVars);
66+
npmScriptRunner.AttachToLogger(logger);
67+
68+
Match openBrowserLine;
69+
using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr))
70+
{
71+
try
72+
{
73+
openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch(
74+
new Regex("Local:\\s*(http\\S+)", RegexOptions.None, RegexMatchTimeout),
75+
StartupTimeout);
76+
}
77+
catch (EndOfStreamException ex)
78+
{
79+
throw new InvalidOperationException(
80+
$"The NPM script '{npmScriptName}' exited without indicating that the " +
81+
$"create-react-app server was listening for requests. The error output was: " +
82+
$"{stdErrReader.ReadAsString()}", ex);
83+
}
84+
catch (TaskCanceledException ex)
85+
{
86+
throw new InvalidOperationException(
87+
$"The create-react-app server did not start listening for requests " +
88+
$"within the timeout period of {StartupTimeout.Seconds} seconds. " +
89+
$"Check the log output for error information.", ex);
90+
}
91+
}
92+
93+
var uri = new Uri(openBrowserLine.Groups[1].Value);
94+
return uri.Port;
95+
}
96+
}
97+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using Microsoft.AspNetCore.Builder;
5+
using System;
6+
7+
namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
8+
{
9+
/// <summary>
10+
/// Extension methods for enabling React development server middleware support.
11+
/// </summary>
12+
public static class ReactDevelopmentServerMiddlewareExtensions
13+
{
14+
/// <summary>
15+
/// Handles requests by passing them through to an instance of the create-react-app server.
16+
/// This means you can always serve up-to-date CLI-built resources without having
17+
/// to run the create-react-app server manually.
18+
///
19+
/// This feature should only be used in development. For production deployments, be
20+
/// sure not to enable the create-react-app server.
21+
/// </summary>
22+
/// <param name="spaBuilder">The <see cref="ISpaBuilder"/>.</param>
23+
/// <param name="npmScript">The name of the script in your package.json file that launches the create-react-app server.</param>
24+
public static void UseReactDevelopmentServer(
25+
this ISpaBuilder spaBuilder,
26+
string npmScript)
27+
{
28+
if (spaBuilder == null)
29+
{
30+
throw new ArgumentNullException(nameof(spaBuilder));
31+
}
32+
33+
var spaOptions = spaBuilder.Options;
34+
35+
if (string.IsNullOrEmpty(spaOptions.SourcePath))
36+
{
37+
throw new InvalidOperationException($"To use {nameof(UseReactDevelopmentServer)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}.");
38+
}
39+
40+
ReactDevelopmentServerMiddleware.Attach(spaBuilder, npmScript);
41+
}
42+
}
43+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using Microsoft.AspNetCore.Builder;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Logging;
7+
using Microsoft.Extensions.Logging.Console;
8+
9+
namespace Microsoft.AspNetCore.SpaServices.Util
10+
{
11+
internal static class LoggerFinder
12+
{
13+
public static ILogger GetOrCreateLogger(
14+
IApplicationBuilder appBuilder,
15+
string logCategoryName)
16+
{
17+
// If the DI system gives us a logger, use it. Otherwise, set up a default one.
18+
var loggerFactory = appBuilder.ApplicationServices.GetService<ILoggerFactory>();
19+
var logger = loggerFactory != null
20+
? loggerFactory.CreateLogger(logCategoryName)
21+
: new ConsoleLogger(logCategoryName, null, false);
22+
return logger;
23+
}
24+
}
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Net;
5+
using System.Net.Sockets;
6+
7+
namespace Microsoft.AspNetCore.SpaServices.Util
8+
{
9+
internal static class TcpPortFinder
10+
{
11+
public static int FindAvailablePort()
12+
{
13+
var listener = new TcpListener(IPAddress.Loopback, 0);
14+
listener.Start();
15+
try
16+
{
17+
return ((IPEndPoint)listener.LocalEndpoint).Port;
18+
}
19+
finally
20+
{
21+
listener.Stop();
22+
}
23+
}
24+
}
25+
}

0 commit comments

Comments
 (0)