From 94fc84a9b457839802831544daab1a0da88fbec1 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 3 Jul 2017 14:07:28 +0100 Subject: [PATCH] Add simpler prerendering API. Fixes #607 --- .../Controllers/HomeController.cs | 15 ++++++ .../Node/prerenderPage.js | 14 ++++++ .../NodeServicesExamples.csproj | 2 +- samples/misc/NodeServicesExamples/Startup.cs | 1 + .../Views/Home/Index.cshtml | 1 + .../Views/Home/Prerendering.cshtml | 21 ++++++++ .../misc/NodeServicesExamples/package.json | 1 + .../Prerendering/DefaultSpaPrerenderer.cs | 49 +++++++++++++++++++ .../Prerendering/ISpaPrerenderer.cs | 28 +++++++++++ .../Prerendering/PrerenderTagHelper.cs | 37 ++------------ .../Prerendering/Prerenderer.cs | 36 ++++++++++++++ ...PrerenderingServiceCollectionExtensions.cs | 27 ++++++++++ .../Prerendering/RenderToStringResult.cs | 26 ++++++++++ 13 files changed, 225 insertions(+), 33 deletions(-) create mode 100644 samples/misc/NodeServicesExamples/Node/prerenderPage.js create mode 100644 samples/misc/NodeServicesExamples/Views/Home/Prerendering.cshtml create mode 100644 src/Microsoft.AspNetCore.SpaServices/Prerendering/DefaultSpaPrerenderer.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices/Prerendering/ISpaPrerenderer.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderingServiceCollectionExtensions.cs diff --git a/samples/misc/NodeServicesExamples/Controllers/HomeController.cs b/samples/misc/NodeServicesExamples/Controllers/HomeController.cs index 7a4f804..d9f3241 100755 --- a/samples/misc/NodeServicesExamples/Controllers/HomeController.cs +++ b/samples/misc/NodeServicesExamples/Controllers/HomeController.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.NodeServices; +using Microsoft.AspNetCore.SpaServices.Prerendering; namespace NodeServicesExamples.Controllers { @@ -34,6 +35,20 @@ namespace NodeServicesExamples.Controllers return View(); } + public async Task Prerendering([FromServices] ISpaPrerenderer prerenderer) + { + var result = await prerenderer.RenderToString("./Node/prerenderPage"); + + if (!string.IsNullOrEmpty(result.RedirectUrl)) + { + return Redirect(result.RedirectUrl); + } + + ViewData["PrerenderedHtml"] = result.Html; + ViewData["PrerenderedGlobals"] = result.CreateGlobalsAssignmentScript(); + return View(); + } + public IActionResult Error() { return View("~/Views/Shared/Error.cshtml"); diff --git a/samples/misc/NodeServicesExamples/Node/prerenderPage.js b/samples/misc/NodeServicesExamples/Node/prerenderPage.js new file mode 100644 index 0000000..7912a6f --- /dev/null +++ b/samples/misc/NodeServicesExamples/Node/prerenderPage.js @@ -0,0 +1,14 @@ +var createServerRenderer = require('aspnet-prerendering').createServerRenderer; + +module.exports = createServerRenderer(function(params) { + return new Promise(function (resolve, reject) { + var message = 'The HTML was returned by the prerendering boot function. ' + + 'The boot function received the following params:' + + '
' + JSON.stringify(params, null, 4) + '
'; + + resolve({ + html: '

Hello, world!

' + message, + globals: { sampleData: { nodeVersion: process.version } } + }); + }); +}); diff --git a/samples/misc/NodeServicesExamples/NodeServicesExamples.csproj b/samples/misc/NodeServicesExamples/NodeServicesExamples.csproj index 4bbb9ec..eedacab 100644 --- a/samples/misc/NodeServicesExamples/NodeServicesExamples.csproj +++ b/samples/misc/NodeServicesExamples/NodeServicesExamples.csproj @@ -9,7 +9,7 @@ - + diff --git a/samples/misc/NodeServicesExamples/Startup.cs b/samples/misc/NodeServicesExamples/Startup.cs index 4ef4802..26f2ae8 100755 --- a/samples/misc/NodeServicesExamples/Startup.cs +++ b/samples/misc/NodeServicesExamples/Startup.cs @@ -17,6 +17,7 @@ namespace NodeServicesExamples // Enable Node Services services.AddNodeServices(); + services.AddSpaPrerenderer(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. diff --git a/samples/misc/NodeServicesExamples/Views/Home/Index.cshtml b/samples/misc/NodeServicesExamples/Views/Home/Index.cshtml index 0b13844..bbdc563 100644 --- a/samples/misc/NodeServicesExamples/Views/Home/Index.cshtml +++ b/samples/misc/NodeServicesExamples/Views/Home/Index.cshtml @@ -9,4 +9,5 @@ diff --git a/samples/misc/NodeServicesExamples/Views/Home/Prerendering.cshtml b/samples/misc/NodeServicesExamples/Views/Home/Prerendering.cshtml new file mode 100644 index 0000000..bd5fff9 --- /dev/null +++ b/samples/misc/NodeServicesExamples/Views/Home/Prerendering.cshtml @@ -0,0 +1,21 @@ +

Server-side prerendering

+ +

+ This sample demonstrates how you can invoke a JavaScript module that contains + prerendering logic for a Single-Page Application framework. +

+

+ Your prerendering boot function will receive parameters that describe the page + being rendered and any data supplied by the .NET code. The return value should be + a promise that resolves with data to be injected into the page, such as the + rendered HTML and any global data that should be made available to client-side code. +

+ +@Html.Raw(ViewData["PrerenderedHtml"]) + + + + diff --git a/samples/misc/NodeServicesExamples/package.json b/samples/misc/NodeServicesExamples/package.json index 58b8a32..9787758 100644 --- a/samples/misc/NodeServicesExamples/package.json +++ b/samples/misc/NodeServicesExamples/package.json @@ -2,6 +2,7 @@ "name": "nodeservicesexamples", "version": "0.0.0", "dependencies": { + "aspnet-prerendering": "^2.0.6", "babel-core": "^6.7.4", "babel-preset-es2015": "^6.6.0", "node-chartist": "^1.0.2" diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/DefaultSpaPrerenderer.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/DefaultSpaPrerenderer.cs new file mode 100644 index 0000000..946f96d --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/DefaultSpaPrerenderer.cs @@ -0,0 +1,49 @@ +using System.Threading; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.NodeServices; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.SpaServices.Prerendering +{ + /// + /// Default implementation of a DI service that provides convenient access to + /// server-side prerendering APIs. This is an alternative to prerendering via + /// the asp-prerender-module tag helper. + /// + internal class DefaultSpaPrerenderer : ISpaPrerenderer + { + private readonly string _applicationBasePath; + private readonly CancellationToken _applicationStoppingToken; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly INodeServices _nodeServices; + + public DefaultSpaPrerenderer( + INodeServices nodeServices, + IApplicationLifetime applicationLifetime, + IHostingEnvironment hostingEnvironment, + IHttpContextAccessor httpContextAccessor) + { + _applicationBasePath = hostingEnvironment.ContentRootPath; + _applicationStoppingToken = applicationLifetime.ApplicationStopping; + _httpContextAccessor = httpContextAccessor; + _nodeServices = nodeServices; + } + + public Task RenderToString( + string moduleName, + string exportName = null, + object customDataParameter = null, + int timeoutMilliseconds = default(int)) + { + return Prerenderer.RenderToString( + _applicationBasePath, + _nodeServices, + _applicationStoppingToken, + new JavaScriptModuleExport(moduleName) { ExportName = exportName }, + _httpContextAccessor.HttpContext, + customDataParameter, + timeoutMilliseconds); + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/ISpaPrerenderer.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/ISpaPrerenderer.cs new file mode 100644 index 0000000..183d4ae --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/ISpaPrerenderer.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.SpaServices.Prerendering +{ + /// + /// Represents a service that can perform server-side prerendering for + /// JavaScript-based Single Page Applications. This is an alternative + /// to using the 'asp-prerender-module' tag helper. + /// + public interface ISpaPrerenderer + { + /// + /// Invokes JavaScript code to perform server-side prerendering for a + /// Single-Page Application. This is an alternative to using the + /// 'asp-prerender-module' tag helper. + /// + /// The JavaScript module that exports a prerendering function. + /// The name of the export from the JavaScript module, if it is not the default export. + /// An optional JSON-serializable object to pass to the JavaScript prerendering function. + /// If specified, the prerendering task will time out after this duration if not already completed. + /// + Task RenderToString( + string moduleName, + string exportName = null, + object customDataParameter = null, + int timeoutMilliseconds = default(int)); + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderTagHelper.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderTagHelper.cs index c2c5308..ffa982e 100644 --- a/src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderTagHelper.cs +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderTagHelper.cs @@ -1,14 +1,11 @@ using System; -using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.NodeServices; using Microsoft.AspNetCore.Razor.TagHelpers; -using Newtonsoft.Json; namespace Microsoft.AspNetCore.SpaServices.Prerendering { @@ -90,19 +87,6 @@ namespace Microsoft.AspNetCore.SpaServices.Prerendering /// A representing the operation. public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) { - // We want to pass the original, unencoded incoming URL data through to Node, so that - // server-side code has the same view of the URL as client-side code (on the client, - // location.pathname returns an unencoded string). - // The following logic handles special characters in URL paths in the same way that - // Node and client-side JS does. For example, the path "/a=b%20c" gets passed through - // unchanged (whereas other .NET APIs do change it - Path.Value will return it as - // "/a=b c" and Path.ToString() will return it as "/a%3db%20c") - var requestFeature = ViewContext.HttpContext.Features.Get(); - var unencodedPathAndQuery = requestFeature.RawTarget; - - var request = ViewContext.HttpContext.Request; - var unencodedAbsoluteUrl = $"{request.Scheme}://{request.Host}{unencodedPathAndQuery}"; - var result = await Prerenderer.RenderToString( _applicationBasePath, _nodeServices, @@ -111,11 +95,9 @@ namespace Microsoft.AspNetCore.SpaServices.Prerendering { ExportName = ExportName }, - unencodedAbsoluteUrl, - unencodedPathAndQuery, + ViewContext.HttpContext, CustomDataParameter, - TimeoutMillisecondsParameter, - request.PathBase.ToString()); + TimeoutMillisecondsParameter); if (!string.IsNullOrEmpty(result.RedirectUrl)) { @@ -134,19 +116,10 @@ namespace Microsoft.AspNetCore.SpaServices.Prerendering // Also attach any specified globals to the 'window' object. This is useful for transferring // general state between server and client. - if (result.Globals != null) + var globalsScript = result.CreateGlobalsAssignmentScript(); + if (!string.IsNullOrEmpty(globalsScript)) { - var stringBuilder = new StringBuilder(); - foreach (var property in result.Globals.Properties()) - { - stringBuilder.AppendFormat("window.{0} = {1};", - property.Name, - property.Value.ToString(Formatting.None)); - } - if (stringBuilder.Length > 0) - { - output.PostElement.SetHtmlContent($""); - } + output.PostElement.SetHtmlContent($""); } } } diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/Prerenderer.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/Prerenderer.cs index e7ce841..4e2fb16 100644 --- a/src/Microsoft.AspNetCore.SpaServices/Prerendering/Prerenderer.cs +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/Prerenderer.cs @@ -2,6 +2,8 @@ using System; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.NodeServices; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; namespace Microsoft.AspNetCore.SpaServices.Prerendering { @@ -14,6 +16,40 @@ namespace Microsoft.AspNetCore.SpaServices.Prerendering private static StringAsTempFile NodeScript; + internal static Task RenderToString( + string applicationBasePath, + INodeServices nodeServices, + CancellationToken applicationStoppingToken, + JavaScriptModuleExport bootModule, + HttpContext httpContext, + object customDataParameter, + int timeoutMilliseconds) + { + // We want to pass the original, unencoded incoming URL data through to Node, so that + // server-side code has the same view of the URL as client-side code (on the client, + // location.pathname returns an unencoded string). + // The following logic handles special characters in URL paths in the same way that + // Node and client-side JS does. For example, the path "/a=b%20c" gets passed through + // unchanged (whereas other .NET APIs do change it - Path.Value will return it as + // "/a=b c" and Path.ToString() will return it as "/a%3db%20c") + var requestFeature = httpContext.Features.Get(); + var unencodedPathAndQuery = requestFeature.RawTarget; + + var request = httpContext.Request; + var unencodedAbsoluteUrl = $"{request.Scheme}://{request.Host}{unencodedPathAndQuery}"; + + return RenderToString( + applicationBasePath, + nodeServices, + applicationStoppingToken, + bootModule, + unencodedAbsoluteUrl, + unencodedPathAndQuery, + customDataParameter, + timeoutMilliseconds, + request.PathBase.ToString()); + } + /// /// Performs server-side prerendering by invoking code in Node.js. /// diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderingServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderingServiceCollectionExtensions.cs new file mode 100644 index 0000000..2a6eb0b --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderingServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.NodeServices; +using Microsoft.AspNetCore.SpaServices.Prerendering; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods for setting up prerendering features in an . + /// + public static class PrerenderingServiceCollectionExtensions + { + /// + /// Configures the dependency injection system to supply an implementation + /// of . + /// + /// The . + public static void AddSpaPrerenderer(this IServiceCollection serviceCollection) + { + serviceCollection.TryAddSingleton(); + serviceCollection.AddSingleton(); + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/RenderToStringResult.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/RenderToStringResult.cs index a213baf..1a2e156 100644 --- a/src/Microsoft.AspNetCore.SpaServices/Prerendering/RenderToStringResult.cs +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/RenderToStringResult.cs @@ -1,4 +1,6 @@ +using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using System.Text; namespace Microsoft.AspNetCore.SpaServices.Prerendering { @@ -30,5 +32,29 @@ namespace Microsoft.AspNetCore.SpaServices.Prerendering /// If set, specifies the HTTP status code that should be sent back with the server response. /// public int? StatusCode { get; set; } + + /// + /// Constructs a block of JavaScript code that assigns data from the + /// property to the global namespace. + /// + /// A block of JavaScript code. + public string CreateGlobalsAssignmentScript() + { + if (Globals == null) + { + return string.Empty; + } + + var stringBuilder = new StringBuilder(); + + foreach (var property in Globals.Properties()) + { + stringBuilder.AppendFormat("window.{0} = {1};", + property.Name, + property.Value.ToString(Formatting.None)); + } + + return stringBuilder.ToString(); + } } } \ No newline at end of file