Skip to content

Commit c47d5bf

Browse files
Add SPA middleware APIs to support new templates
1 parent e67a301 commit c47d5bf

15 files changed

+698
-34
lines changed
Lines changed: 43 additions & 0 deletions
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.SpaServices.Prerendering;
5+
using System;
6+
using System.Linq;
7+
using System.Threading.Tasks;
8+
9+
namespace Microsoft.AspNetCore.SpaServices.AngularCli
10+
{
11+
/// <summary>
12+
/// Provides an implementation of <see cref="ISpaPrerendererBuilder"/> that can build
13+
/// an Angular application by invoking the Angular CLI.
14+
/// </summary>
15+
public class AngularCliBuilder : ISpaPrerendererBuilder
16+
{
17+
private readonly string _cliAppName;
18+
19+
/// <summary>
20+
/// Constructs an instance of <see cref="AngularCliBuilder"/>.
21+
/// </summary>
22+
/// <param name="cliAppName">The name of the application to be built. This must match an entry in your <c>.angular-cli.json</c> file.</param>
23+
public AngularCliBuilder(string cliAppName)
24+
{
25+
_cliAppName = cliAppName;
26+
}
27+
28+
/// <inheritdoc />
29+
public Task Build(ISpaBuilder spaBuilder)
30+
{
31+
// Locate the AngularCliMiddleware within the provided ISpaBuilder
32+
var angularCliMiddleware = spaBuilder
33+
.Properties.Keys.OfType<AngularCliMiddleware>().FirstOrDefault();
34+
if (angularCliMiddleware == null)
35+
{
36+
throw new Exception(
37+
$"Cannot use {nameof (AngularCliBuilder)} unless you are also using {nameof(AngularCliMiddleware)}.");
38+
}
39+
40+
return angularCliMiddleware.StartAngularCliBuilderAsync(_cliAppName);
41+
}
42+
}
43+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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;
5+
using System.IO;
6+
using Microsoft.AspNetCore.NodeServices;
7+
using System.Threading.Tasks;
8+
using Microsoft.AspNetCore.Builder;
9+
using Microsoft.AspNetCore.Hosting;
10+
using System.Threading;
11+
using Microsoft.AspNetCore.SpaServices.Proxy;
12+
13+
namespace Microsoft.AspNetCore.SpaServices.AngularCli
14+
{
15+
internal class AngularCliMiddleware
16+
{
17+
private const string _middlewareResourceName = "/Content/Node/angular-cli-middleware.js";
18+
19+
private readonly INodeServices _nodeServices;
20+
private readonly string _middlewareScriptPath;
21+
22+
public AngularCliMiddleware(ISpaBuilder spaBuilder, string sourcePath)
23+
{
24+
if (string.IsNullOrEmpty(sourcePath))
25+
{
26+
throw new ArgumentException("Cannot be null or empty", nameof(sourcePath));
27+
}
28+
29+
// Prepare to make calls into Node
30+
var appBuilder = spaBuilder.AppBuilder;
31+
_nodeServices = CreateNodeServicesInstance(appBuilder, sourcePath);
32+
_middlewareScriptPath = GetAngularCliMiddlewareScriptPath(appBuilder);
33+
34+
// Start Angular CLI and attach to middleware pipeline
35+
var angularCliServerInfoTask = StartAngularCliServerAsync();
36+
spaBuilder.AddStartupTask(angularCliServerInfoTask);
37+
38+
// Proxy the corresponding requests through ASP.NET and into the Node listener
39+
// Anything under /<publicpath> (e.g., /dist) is proxied as a normal HTTP request
40+
// with a typical timeout (100s is the default from HttpClient).
41+
UseProxyToLocalAngularCliMiddleware(appBuilder, spaBuilder.PublicPath,
42+
angularCliServerInfoTask, TimeSpan.FromSeconds(100));
43+
44+
// Advertise the availability of this feature to other SPA middleware
45+
spaBuilder.Properties.Add(this, null);
46+
}
47+
48+
public Task StartAngularCliBuilderAsync(string cliAppName)
49+
{
50+
return _nodeServices.InvokeExportAsync<AngularCliServerInfo>(
51+
_middlewareScriptPath,
52+
"startAngularCliBuilder",
53+
cliAppName);
54+
}
55+
56+
private static INodeServices CreateNodeServicesInstance(
57+
IApplicationBuilder appBuilder, string sourcePath)
58+
{
59+
// Unlike other consumers of NodeServices, AngularCliMiddleware dosen't share Node instances, nor does it
60+
// use your DI configuration. It's important for AngularCliMiddleware to have its own private Node instance
61+
// because it must *not* restart when files change (it's designed to watch for changes and rebuild).
62+
var nodeServicesOptions = new NodeServicesOptions(appBuilder.ApplicationServices)
63+
{
64+
WatchFileExtensions = new string[] { }, // Don't watch anything
65+
ProjectPath = Path.Combine(Directory.GetCurrentDirectory(), sourcePath),
66+
};
67+
68+
return NodeServicesFactory.CreateNodeServices(nodeServicesOptions);
69+
}
70+
71+
private static string GetAngularCliMiddlewareScriptPath(IApplicationBuilder appBuilder)
72+
{
73+
var script = EmbeddedResourceReader.Read(typeof(AngularCliMiddleware), _middlewareResourceName);
74+
var nodeScript = new StringAsTempFile(script, GetStoppingToken(appBuilder));
75+
return nodeScript.FileName;
76+
}
77+
78+
private static CancellationToken GetStoppingToken(IApplicationBuilder appBuilder)
79+
{
80+
var applicationLifetime = appBuilder
81+
.ApplicationServices
82+
.GetService(typeof(IApplicationLifetime));
83+
return ((IApplicationLifetime)applicationLifetime).ApplicationStopping;
84+
}
85+
86+
private async Task<AngularCliServerInfo> StartAngularCliServerAsync()
87+
{
88+
// Tell Node to start the server hosting the Angular CLI
89+
var angularCliServerInfo = await _nodeServices.InvokeExportAsync<AngularCliServerInfo>(
90+
_middlewareScriptPath,
91+
"startAngularCliServer");
92+
93+
// Even after the Angular CLI claims to be listening for requests, there's a short
94+
// period where it will give an error if you make a request too quickly. Give it
95+
// a moment to finish starting up.
96+
await Task.Delay(500);
97+
98+
return angularCliServerInfo;
99+
}
100+
101+
private static void UseProxyToLocalAngularCliMiddleware(
102+
IApplicationBuilder appBuilder, string publicPath,
103+
Task<AngularCliServerInfo> serverInfoTask, TimeSpan requestTimeout)
104+
{
105+
// This is hardcoded to use http://localhost because:
106+
// - the requests are always from the local machine (we're not accepting remote
107+
// requests that go directly to the Angular CLI middleware server)
108+
// - given that, there's no reason to use https, and we couldn't even if we
109+
// wanted to, because in general the Angular CLI server has no certificate
110+
var proxyOptionsTask = serverInfoTask.ContinueWith(
111+
task => new ConditionalProxyMiddlewareTarget(
112+
"http", "localhost", task.Result.Port.ToString()));
113+
appBuilder.UseMiddleware<ConditionalProxyMiddleware>(publicPath, requestTimeout, proxyOptionsTask);
114+
}
115+
116+
#pragma warning disable CS0649
117+
class AngularCliServerInfo
118+
{
119+
public int Port { get; set; }
120+
}
121+
}
122+
#pragma warning restore CS0649
123+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
namespace Microsoft.AspNetCore.SpaServices.AngularCli
5+
{
6+
/// <summary>
7+
/// Extension methods for enabling Angular CLI middleware support.
8+
/// </summary>
9+
public static class AngularCliMiddlewareExtensions
10+
{
11+
/// <summary>
12+
/// Enables Angular CLI middleware support. This hosts an instance of the Angular CLI in memory in
13+
/// your application so that you can always serve up-to-date CLI-built resources without having
14+
/// to run CLI server manually.
15+
///
16+
/// Incoming requests that match Angular CLI-built files will be handled by returning the CLI server
17+
/// output directly.
18+
///
19+
/// This feature should only be used in development. For production deployments, be sure not to
20+
/// enable Angular CLI middleware.
21+
/// </summary>
22+
/// <param name="spaBuilder">The <see cref="ISpaBuilder"/>.</param>
23+
/// <param name="sourcePath">The path, relative to the application root, of the directory containing the SPA source files.</param>
24+
public static void UseAngularCliMiddleware(
25+
this ISpaBuilder spaBuilder,
26+
string sourcePath)
27+
{
28+
new AngularCliMiddleware(spaBuilder, sourcePath);
29+
}
30+
}
31+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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+
var childProcess = require('child_process');
5+
var net = require('net');
6+
var readline = require('readline');
7+
var url = require('url');
8+
9+
module.exports = {
10+
startAngularCliBuilder: function startAngularCliBuilder(callback, appName) {
11+
var proc = executeAngularCli([
12+
'build',
13+
'-app', appName,
14+
'--watch'
15+
]);
16+
proc.stdout.pipe(process.stdout);
17+
waitForLine(proc.stdout, /chunk/).then(function () {
18+
callback();
19+
});
20+
},
21+
22+
startAngularCliServer: function startAngularCliServer(callback, options) {
23+
getOSAssignedPortNumber().then(function (portNumber) {
24+
// Start @angular/cli dev server on private port, and pipe its output
25+
// back to the ASP.NET host process.
26+
// TODO: Support streaming arbitrary chunks to host process's stdout
27+
// rather than just full lines, so we can see progress being logged
28+
var devServerProc = executeAngularCli([
29+
'serve',
30+
'--port', portNumber.toString(),
31+
'--deploy-url', '/dist/', // Value should come from .angular-cli.json, but https://github.com/angular/angular-cli/issues/7347
32+
'--extract-css'
33+
]);
34+
devServerProc.stdout.pipe(process.stdout);
35+
36+
// Wait until the CLI dev server is listening before letting ASP.NET start the app
37+
console.log('Waiting for @angular/cli service to start...');
38+
waitForLine(devServerProc.stdout, /open your browser on (http\S+)/).then(function (matches) {
39+
var devServerUrl = url.parse(matches[1]);
40+
console.log('@angular/cli service has started on internal port ' + devServerUrl.port);
41+
callback(null, {
42+
Port: parseInt(devServerUrl.port)
43+
});
44+
});
45+
});
46+
}
47+
};
48+
49+
function waitForLine(stream, regex) {
50+
return new Promise(function (resolve, reject) {
51+
var lineReader = readline.createInterface({ input: stream });
52+
lineReader.on('line', function (line) {
53+
var matches = regex.exec(line);
54+
if (matches) {
55+
lineReader.close();
56+
resolve(matches);
57+
}
58+
});
59+
});
60+
}
61+
62+
function executeAngularCli(args) {
63+
var angularCliBin = require.resolve('@angular/cli/bin/ng');
64+
return childProcess.fork(angularCliBin, args, {
65+
stdio: [/* stdin */ 'ignore', /* stdout */ 'pipe', /* stderr */ 'inherit', 'ipc']
66+
});
67+
}
68+
69+
function getOSAssignedPortNumber() {
70+
return new Promise(function (resolve, reject) {
71+
var server = net.createServer();
72+
server.listen(0, 'localhost', function () {
73+
var portNumber = server.address().port;
74+
server.close(function () { resolve(portNumber); });
75+
});
76+
});
77+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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.AspNetCore.Http;
6+
using System.Collections.Generic;
7+
using System.Threading.Tasks;
8+
9+
namespace Microsoft.AspNetCore.SpaServices
10+
{
11+
internal class DefaultSpaBuilder : ISpaBuilder
12+
{
13+
private readonly object _startupTasksLock = new object();
14+
15+
public DefaultSpaBuilder(IApplicationBuilder appBuilder, string publicPath, PathString defaultFilePath)
16+
{
17+
AppBuilder = appBuilder;
18+
DefaultFilePath = defaultFilePath;
19+
Properties = new Dictionary<object, object>();
20+
PublicPath = publicPath;
21+
}
22+
23+
public IApplicationBuilder AppBuilder { get; }
24+
public PathString DefaultFilePath { get; }
25+
public IDictionary<object, object> Properties { get; }
26+
public string PublicPath { get; }
27+
public Task StartupTasks { get; private set; } = Task.CompletedTask;
28+
29+
public void AddStartupTask(Task task)
30+
{
31+
lock (_startupTasksLock)
32+
{
33+
StartupTasks = Task.WhenAll(StartupTasks, task);
34+
}
35+
}
36+
}
37+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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.AspNetCore.Http;
6+
using System.Collections.Generic;
7+
using System.Threading.Tasks;
8+
9+
namespace Microsoft.AspNetCore.SpaServices
10+
{
11+
/// <summary>
12+
/// Defines a class that provides mechanisms to configure a Single Page Application
13+
/// being hosted by an ASP.NET server.
14+
/// </summary>
15+
public interface ISpaBuilder
16+
{
17+
/// <summary>
18+
/// Gets the <see cref="IApplicationBuilder"/> for the host application.
19+
/// </summary>
20+
IApplicationBuilder AppBuilder { get; }
21+
22+
/// <summary>
23+
/// Gets the path to the SPA's default file. By default, this is the file
24+
/// index.html within the <see cref="PublicPath"/>.
25+
/// </summary>
26+
PathString DefaultFilePath { get; }
27+
28+
/// <summary>
29+
/// Gets the URL path, relative to the application's <c>PathBase</c>, from which
30+
/// the SPA files are served.
31+
///</summary>
32+
///<example>
33+
/// If the SPA files are located in <c>wwwroot/dist</c>, then the value would
34+
/// usually be <c>"dist"</c>, because that is the URL prefix from which clients
35+
/// can request those files.
36+
///</example>
37+
string PublicPath { get; }
38+
39+
/// <summary>
40+
/// Gets a key/value collection that can be used to share data between SPA middleware.
41+
/// </summary>
42+
IDictionary<object, object> Properties { get; }
43+
44+
/// <summary>
45+
/// Gets a <see cref="Task"/> that represents the completion of all registered
46+
/// SPA startup tasks.
47+
/// </summary>
48+
Task StartupTasks { get; }
49+
50+
/// <summary>
51+
/// Registers a task that represents part of SPA startup process. Middleware
52+
/// may choose to wait for these tasks to complete before taking some action.
53+
/// </summary>
54+
/// <param name="task">The <see cref="Task"/>.</param>
55+
void AddStartupTask(Task task);
56+
}
57+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<ItemGroup>
1818
<PackageReference Include="Microsoft.AspNetCore.Mvc.TagHelpers" />
1919
<PackageReference Include="Microsoft.AspNetCore.Mvc.ViewFeatures" />
20+
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" />
2021
</ItemGroup>
2122

2223
<Target Name="PrepublishScript" BeforeTargets="PrepareForPublish" Condition=" '$(IsCrossTargetingBuild)' != 'true' ">

0 commit comments

Comments
 (0)