diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/VueCli/VueDevelopmentServerMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/VueCli/VueDevelopmentServerMiddleware.cs new file mode 100644 index 00000000..9182f8c1 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/VueCli/VueDevelopmentServerMiddleware.cs @@ -0,0 +1,105 @@ +// 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.Builder; +using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.NodeServices.Npm; +using Microsoft.AspNetCore.NodeServices.Util; +using Microsoft.AspNetCore.SpaServices.Util; +using System; +using System.IO; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SpaServices.Extensions.Util; + +namespace Microsoft.AspNetCore.SpaServices.VueCli +{ + internal static class VueCliMiddleware + { + private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices"; + private static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine + + public static void Attach( + ISpaBuilder spaBuilder, + string npmScriptName, int port = 0) + { + var sourcePath = spaBuilder.Options.SourcePath; + if (string.IsNullOrEmpty(sourcePath)) + { + throw new ArgumentException("Cannot be null or empty", nameof(sourcePath)); + } + + if (string.IsNullOrEmpty(npmScriptName)) + { + throw new ArgumentException("Cannot be null or empty", nameof(npmScriptName)); + } + + // Start vue-cli and attach to middleware pipeline + var appBuilder = spaBuilder.ApplicationBuilder; + var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName); + var portTask = StartVueCliServerAsync(sourcePath, npmScriptName, logger, port); + + // Everything we proxy is hardcoded to target http://localhost because: + // - the requests are always from the local machine (we're not accepting remote + // requests that go directly to the vue-cli server) + // - given that, there's no reason to use https, and we couldn't even if we + // wanted to, because in general the vue-cli server has no certificate + var targetUriTask = portTask.ContinueWith( + task => + new UriBuilder("http", "localhost", task.Result).Uri); + + SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, () => + { + // On each request, we create a separate startup task with its own timeout. That way, even if + // the first request times out, subsequent requests could still work. + var timeout = spaBuilder.Options.StartupTimeout; + return targetUriTask.WithTimeout(timeout, + $"The vue-cli server did not start listening for requests " + + $"within the timeout period of {timeout.Seconds} seconds. " + + $"Check the log output for error information."); + }); + } + + private static async Task StartVueCliServerAsync( + string sourcePath, string npmScriptName, ILogger logger, int portNumber) + { + if (portNumber < 80) + portNumber = TcpPortFinder.FindAvailablePort(); + logger.LogInformation($"Starting server on port {portNumber}..."); + + var envVars = new Dictionary + { + { "PORT", portNumber.ToString() }, + { "DEV_SERVER_PORT", portNumber.ToString() }, // vue cli 3 uses --port {number}, included below + { "BROWSER", "none" }, // We don't want vue-cli to open its own extra browser window pointing to the internal dev server port + }; + var npmScriptRunner = new NpmScriptRunner(sourcePath, npmScriptName, $"--port {portNumber}", envVars); + npmScriptRunner.AttachToLogger(logger); + + using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr)) + { + try + { + // Although the Vue dev server may eventually tell us the URL it's listening on, + // it doesn't do so until it's finished compiling, and even then only if there were + // no compiler warnings. So instead of waiting for that, consider it ready as soon + // as it starts listening for requests. + await npmScriptRunner.StdOut.WaitForMatch( + new Regex("running at", RegexOptions.None, RegexMatchTimeout)); + } + catch (EndOfStreamException ex) + { + throw new InvalidOperationException( + $"The NPM script '{npmScriptName}' exited without indicating that the " + + $"server was listening for requests. The error output was: " + + $"{stdErrReader.ReadAsString()}", ex); + } + } + + return portNumber; + } + } + + +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/VueCli/VueDevelopmentServerMiddlewareExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/VueCli/VueDevelopmentServerMiddlewareExtensions.cs new file mode 100644 index 00000000..76965bdf --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/VueCli/VueDevelopmentServerMiddlewareExtensions.cs @@ -0,0 +1,44 @@ +// 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.Builder; +using System; + +namespace Microsoft.AspNetCore.SpaServices.VueCli +{ + /// + /// Extension methods for enabling Vue development server middleware support. + /// + public static class VueCliMiddlewareExtensions + { + /// + /// Handles requests by passing them through to an instance of the vue-cli server. + /// This means you can always serve up-to-date CLI-built resources without having + /// to run the vue-cli server manually. + /// + /// This feature should only be used in development. For production deployments, be + /// sure not to enable the vue-cli server. + /// + /// The . + /// The name of the script in your package.json file that launches the vue-cli server. + /// Specify vue cli server port number. If < 80, uses random port. + public static void UseVueCli( + this ISpaBuilder spaBuilder, + string npmScript, int port = 0) + { + if (spaBuilder == null) + { + throw new ArgumentNullException(nameof(spaBuilder)); + } + + var spaOptions = spaBuilder.Options; + + if (string.IsNullOrEmpty(spaOptions.SourcePath)) + { + throw new InvalidOperationException($"To use {nameof(UseVueCli)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); + } + + VueCliMiddleware.Attach(spaBuilder, npmScript, port); + } + } +}