Skip to content

Commit 755a8d8

Browse files
committed
support vue
1 parent 23dc31e commit 755a8d8

File tree

4 files changed

+245
-0
lines changed

4 files changed

+245
-0
lines changed

src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,8 @@
1515
<PackageReference Include="Microsoft.Extensions.FileProviders.Physical" Version="$(MicrosoftExtensionsFileProvidersPhysicalPackageVersion)" />
1616
</ItemGroup>
1717

18+
<ItemGroup>
19+
<Folder Include="Vue\" />
20+
</ItemGroup>
21+
1822
</Project>

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,46 @@ internal class NpmScriptRunner
2323

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

26+
public NpmScriptRunner(string workingDirectory, string scriptName, string arguments)
27+
{
28+
if (string.IsNullOrEmpty(workingDirectory))
29+
{
30+
throw new ArgumentException("Cannot be null or empty.", nameof(workingDirectory));
31+
}
32+
33+
if (string.IsNullOrEmpty(scriptName))
34+
{
35+
throw new ArgumentException("Cannot be null or empty.", nameof(scriptName));
36+
}
37+
38+
var npmExe = "npm";
39+
var completeArguments = $"run {scriptName} {arguments ?? string.Empty}";
40+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
41+
{
42+
// On Windows, the NPM executable is a .cmd file, so it can't be executed
43+
// directly (except with UseShellExecute=true, but that's no good, because
44+
// it prevents capturing stdio). So we need to invoke it via "cmd /c".
45+
npmExe = "cmd";
46+
completeArguments = $"/c npm {completeArguments}";
47+
}
48+
49+
var processStartInfo = new ProcessStartInfo(npmExe)
50+
{
51+
Arguments = completeArguments,
52+
UseShellExecute = false,
53+
RedirectStandardInput = true,
54+
RedirectStandardOutput = true,
55+
RedirectStandardError = true,
56+
WorkingDirectory = workingDirectory
57+
};
58+
59+
var process = LaunchNodeProcess(processStartInfo);
60+
StdOut = new EventedStreamReader(process.StandardOutput);
61+
//StdOut = new EventedStreamReader(process.StandardOutput);
62+
63+
StdErr = new EventedStreamReader(process.StandardError);
64+
}
65+
2666
public NpmScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary<string, string> envVars)
2767
{
2868
if (string.IsNullOrEmpty(workingDirectory))
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.AspNetCore.NodeServices.Npm;
3+
using Microsoft.AspNetCore.NodeServices.Util;
4+
using Microsoft.AspNetCore.SpaServices.Extensions.Util;
5+
using Microsoft.AspNetCore.SpaServices.Util;
6+
using Microsoft.Extensions.Logging;
7+
using System;
8+
using System.Collections.Generic;
9+
using System.IO;
10+
using System.Net.Http;
11+
using System.Text;
12+
using System.Text.RegularExpressions;
13+
using System.Threading;
14+
using System.Threading.Tasks;
15+
16+
namespace Microsoft.AspNetCore.SpaServices.Extensions.Vue
17+
{
18+
internal static class VueCliMiddleware
19+
{
20+
private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices";
21+
private static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine
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 Vue CLI and attach to middleware pipeline
39+
var appBuilder = spaBuilder.ApplicationBuilder;
40+
var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName);
41+
var angularCliServerInfoTask = StartVueCliServerAsync(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 Vue CLI middleware 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 Vue CLI server has no certificate
48+
var targetUriTask = angularCliServerInfoTask.ContinueWith(
49+
task => new UriBuilder("http", "localhost", task.Result.Port).Uri);
50+
51+
SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, () =>
52+
{
53+
// On each request, we create a separate startup task with its own timeout. That way, even if
54+
// the first request times out, subsequent requests could still work.
55+
var timeout = spaBuilder.Options.StartupTimeout;
56+
return targetUriTask.WithTimeout(timeout,
57+
$"The Vue CLI process did not start listening for requests " +
58+
$"within the timeout period of {timeout.Seconds} seconds. " +
59+
$"Check the log output for error information.");
60+
});
61+
}
62+
63+
private static async Task<VueCliServerInfo> StartVueCliServerAsync(
64+
string sourcePath, string npmScriptName, ILogger logger)
65+
{
66+
var portNumber = TcpPortFinder.FindAvailablePort();
67+
68+
69+
70+
71+
//logger.LogTrace($"Starting Vue/dev-server on port {portNumber}...");
72+
//logger.LogDebug($"Starting Vue/dev-server on port {portNumber}...");
73+
//logger.LogInformation($"Starting Vue/dev-server on port {portNumber}...");
74+
//logger.LogWarning($"Starting Vue/dev-server on port {portNumber}...");
75+
//logger.LogError($"Starting Vue/dev-server on port {portNumber}...");
76+
//logger.LogCritical($"Starting Vue/dev-server on port {portNumber}...");
77+
78+
79+
80+
81+
var npmScriptRunner = new NpmScriptRunner(
82+
//sourcePath, npmScriptName, $"--port {portNumber}");
83+
sourcePath, npmScriptName, $"{portNumber}");
84+
85+
npmScriptRunner.AttachToLogger(logger);
86+
87+
Match openBrowserLine;
88+
using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr))
89+
{
90+
try
91+
{
92+
//openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch(
93+
// new Regex("open your browser on (http\\S+)", RegexOptions.None, RegexMatchTimeout));
94+
openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch(
95+
new Regex("- Local: (http:\\S+/)", RegexOptions.None, RegexMatchTimeout));
96+
}
97+
catch (EndOfStreamException ex)
98+
{
99+
throw new InvalidOperationException(
100+
$"The NPM script '{npmScriptName}' exited without indicating that the " +
101+
$"Angular CLI was listening for requests. The error output was: " +
102+
$"{stdErrReader.ReadAsString()}", ex);
103+
}
104+
}
105+
106+
var uri = new Uri(openBrowserLine.Groups[1].Value);
107+
var serverInfo = new VueCliServerInfo { Port = uri.Port };
108+
109+
// Even after the Angular CLI claims to be listening for requests, there's a short
110+
// period where it will give an error if you make a request too quickly
111+
await WaitForVueCliServerToAcceptRequests(uri);
112+
return serverInfo;
113+
}
114+
115+
private static async Task WaitForVueCliServerToAcceptRequests(Uri cliServerUri)
116+
{
117+
// To determine when it's actually ready, try making HEAD requests to '/'. If it
118+
// produces any HTTP response (even if it's 404) then it's ready. If it rejects the
119+
// connection then it's not ready. We keep trying forever because this is dev-mode
120+
// only, and only a single startup attempt will be made, and there's a further level
121+
// of timeouts enforced on a per-request basis.
122+
var timeoutMilliseconds = 1000;
123+
using (var client = new HttpClient())
124+
{
125+
while (true)
126+
{
127+
try
128+
{
129+
// If we get any HTTP response, the CLI server is ready
130+
await client.SendAsync(
131+
new HttpRequestMessage(HttpMethod.Head, cliServerUri),
132+
new CancellationTokenSource(timeoutMilliseconds).Token);
133+
return;
134+
}
135+
catch (Exception)
136+
{
137+
await Task.Delay(500);
138+
139+
// Depending on the host's networking configuration, the requests can take a while
140+
// to go through, most likely due to the time spent resolving 'localhost'.
141+
// Each time we have a failure, allow a bit longer next time (up to a maximum).
142+
// This only influences the time until we regard the dev server as 'ready', so it
143+
// doesn't affect the runtime perf (even in dev mode) once the first connection is made.
144+
// Resolves https://github.com/aspnet/JavaScriptServices/issues/1611
145+
if (timeoutMilliseconds < 10000)
146+
{
147+
timeoutMilliseconds += 3000;
148+
}
149+
}
150+
}
151+
}
152+
}
153+
154+
class VueCliServerInfo
155+
{
156+
public int Port { get; set; }
157+
}
158+
}
159+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using System;
3+
using System.Collections.Generic;
4+
using System.Text;
5+
6+
namespace Microsoft.AspNetCore.SpaServices.Extensions.Vue
7+
{
8+
/// <summary>
9+
/// Extension methods for enabling Vue CLI middleware support.
10+
/// </summary>
11+
public static class VueCliMiddlewareExtensions
12+
{
13+
/// <summary>
14+
/// Handles requests by passing them through to an instance of the Vue CLI server.
15+
/// This means you can always serve up-to-date CLI-built resources without having
16+
/// to run the Vue CLI server manually.
17+
///
18+
/// This feature should only be used in development. For production deployments, be
19+
/// sure not to enable the Vue CLI server.
20+
/// </summary>
21+
/// <param name="spaBuilder">The <see cref="ISpaBuilder"/>.</param>
22+
/// <param name="npmScript">The name of the script in your package.json file that launches the Vue CLI process.</param>
23+
public static void UseVueCliServer(
24+
this ISpaBuilder spaBuilder,
25+
string npmScript)
26+
{
27+
if (spaBuilder == null)
28+
{
29+
throw new ArgumentNullException(nameof(spaBuilder));
30+
}
31+
32+
var spaOptions = spaBuilder.Options;
33+
34+
if (string.IsNullOrEmpty(spaOptions.SourcePath))
35+
{
36+
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)}.");
37+
}
38+
39+
VueCliMiddleware.Attach(spaBuilder, npmScript);
40+
}
41+
}
42+
}

0 commit comments

Comments
 (0)