Skip to content

Commit

Permalink
Implement optional server-side prerendering support
Browse files Browse the repository at this point in the history
  • Loading branch information
SteveSanderson committed Jun 28, 2017
1 parent 4bd78c8 commit e77120f
Show file tree
Hide file tree
Showing 17 changed files with 419 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ else
{
using (var client = new HttpClient())
{
var json = await client.GetStringAsync("/api/SampleData/WeatherForecasts");
var json = await client.GetStringAsync(AbsoluteUrl("/api/SampleData/WeatherForecasts"));
forecasts = JsonUtil.Deserialize<WeatherForecast[]>(json);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<div class='navbar-collapse collapse'>
<ul class='nav navbar-nav'>
<li>
<a href='~/' class='active'>
<a href='~/'>
<span class='glyphicon glyphicon-home'></span> Home
</a>
</li>
Expand Down
6 changes: 5 additions & 1 deletion samples/ClientServerApp/ClientServerApp.Server/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerF
});

// All other requests handled by serving the SPA
app.UseBlazorUI(Path.Combine("..", "ClientServerApp.Client"));
app.UseBlazorUI(Path.Combine("..", "ClientServerApp.Client"), opts =>
{
opts.EnableServerSidePrerendering = true;
opts.ClientAssemblyName = "ClientServerApp.Client.dll";
});
}
}
}
46 changes: 36 additions & 10 deletions src/Blazor.Host/Host/BlazorApplicationBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,32 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
using System.Text;

namespace Blazor.Host
{
public class BlazorUIOptions
{
public bool EnableServerSidePrerendering { get; set; }
public string ClientAssemblyName { get; set; }
}

public static class BlazorApplicationBuilderExtensions
{
private readonly static Assembly _hostAssembly = typeof(BlazorApplicationBuilderExtensions).GetTypeInfo().Assembly;
private readonly static string _embeddedResourceProjectName = "Blazor.Host"; // Note: Not the same as _hostAssembly.Name

public static IApplicationBuilder UseBlazorUI(this IApplicationBuilder app, string rootPath)
public static IApplicationBuilder UseBlazorUI(this IApplicationBuilder app, string rootPath, Action<BlazorUIOptions> configure = null)
{
var options = new BlazorUIOptions();
if (configure != null)
{
configure(options);
}

var staticFilesRoot = Path.GetFullPath(Path.Combine(rootPath, "wwwroot"));
var fileProvider = new PhysicalFileProvider(staticFilesRoot);

Expand All @@ -26,12 +40,6 @@ public static IApplicationBuilder UseBlazorUI(this IApplicationBuilder app, stri
contentTypeProvider.Mappings.Add(".exe", "application/octet-stream");
contentTypeProvider.Mappings.Add(".wasm", "application/octet-stream");

app.UseDefaultFiles(new DefaultFilesOptions
{
FileProvider = fileProvider,
// RequestPath = requestPath, // TODO: Allow mounting in subdir of URL space
});

app.UseStaticFiles(new StaticFileOptions
{
FileProvider = fileProvider,
Expand Down Expand Up @@ -76,16 +84,34 @@ public static IApplicationBuilder UseBlazorUI(this IApplicationBuilder app, stri

app.UseLiveReloading();

if (options.EnableServerSidePrerendering)
{
if (string.IsNullOrEmpty(options.ClientAssemblyName))
{
throw new ArgumentException($"If {nameof(options.EnableServerSidePrerendering)} is true, then you must specify a value for {nameof(options.ClientAssemblyName)}.");
}
Prerendering.EnablePrerendering(clientBinDir, options.ClientAssemblyName);
}

// SPA fallback routing - for requests that don't match physical files, and don't appear
// to be attempts to fetch static files, map them all to /index.html
app.Use(async (context, next) =>
{
var requestPath = context.Request.Path;
if (!IsStaticFileRequest(requestPath.Value))
{
// TODO: There's probably a better way to return this file using the static files middleware
context.Response.ContentType = "text/html";
await context.Response.SendFileAsync(Path.Combine(rootPath, "wwwroot", "index.html"));
if (options.EnableServerSidePrerendering)
{
var html = await Prerendering.PrerenderPage(rootPath, options.ClientAssemblyName, context);
context.Response.ContentType = "text/html";
await context.Response.WriteAsync(html);
}
else
{
// TODO: There's probably a better way to return this file using the static files middleware
context.Response.ContentType = "text/html";
await context.Response.SendFileAsync(Path.Combine(rootPath, "wwwroot", "index.html"));
}
}
else
{
Expand Down
215 changes: 215 additions & 0 deletions src/Blazor.Host/Host/Prerendering.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
using System.IO;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using Blazor.Components;
using Blazor.Routing;
using Blazor.VirtualDom;
using MiniJSON;
using System.Collections.Generic;
using System;
using System.Runtime.Loader;
using System.Linq;
using Blazor.Sdk.Host;
using Blazor.Runtime.Components;
using Microsoft.AspNetCore.Http;
using System.Diagnostics;

namespace Blazor.Host
{
// The code here is incomplete and uses (uncached) reflection to access internals on Blazor.Runtime
// just as a shortcut to avoid refactoring properly. To clean this up, consider exposing
// the relevant APIs from Blazor.Runtime properly or even using InternalsVisibleTo.

internal static class Prerendering
{
private static string[] viewReferenceAssemblies;

internal static void EnablePrerendering(string clientBinDir, string assemblyName)
{
var clientAppAssemblyPath = Path.Combine(clientBinDir, assemblyName);
var entrypointAssembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(clientAppAssemblyPath);
var entrypoint = entrypointAssembly.EntryPoint;
entrypoint.Invoke(null, new[] { new string[0] });
var envField = typeof(Blazor.Runtime.Env)
.GetField("_isServer", BindingFlags.Static | BindingFlags.NonPublic);
envField.SetValue(null, true);
viewReferenceAssemblies = Directory.EnumerateFiles(clientBinDir, "*.dll")
.Where(binDirEntry => !string.Equals(binDirEntry, assemblyName, StringComparison.OrdinalIgnoreCase))
.ToArray();
}

private static async Task<string> PrerenderUrl(string rootDir, HttpContext httpContext, IEnumerable<Assembly> viewAssemblies)
{
var mountPageFromUrlMethod = typeof(Router).GetMethod("MountPageFromUrl", BindingFlags.Static | BindingFlags.NonPublic);

Router.ViewAssemblies = viewAssemblies;
var component = (Component)mountPageFromUrlMethod.Invoke(null, new object[] {
httpContext.Request.Path.Value,
new BlazorContext(GetAbsoluteUrl(httpContext.Request))
});
return await RenderComponentHtml(component);
}

private static string GetAbsoluteUrl(HttpRequest request)
{
return string.Concat(
request.Scheme,
"://",
request.Host.ToUriComponent(),
request.PathBase.ToUriComponent(),
request.Path.ToUriComponent(),
request.QueryString.ToUriComponent());
}

private static async Task<string> RenderComponentHtml(Component component)
{
var sb = new StringBuilder();
await AppendComponentHtml(sb, component);
return sb.ToString();
}

private static async Task AppendComponentHtml(StringBuilder sb, Component component)
{
var builder = (VDomBuilder)typeof(Component).GetField("builder", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(component);
if (builder == null)
{
throw new NullReferenceException("BAD2");
}
var vdomItems = (VDomItem[])typeof(VDomBuilder).GetProperty("Items", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(builder);
await AppendVDom(sb, component, vdomItems, 0);
}

private static async Task AppendVDom(StringBuilder sb, Component ownerComponent, VDomItem[] vdom, int rootIndex)
{
var rootItem = vdom[rootIndex];
switch (rootItem.ItemType)
{
case VDomItemType.Element:
sb.AppendFormat("<{0}", rootItem.ElementTagName);
var hasClosedTag = false;
for (var childIndex = rootIndex + 1; childIndex <= rootItem.DescendantsEndIndex; childIndex++)
{
// Need to close the tag when we see the first non-attribute child
var childItem = vdom[childIndex];
if (!hasClosedTag)
{

if (childItem.ItemType != VDomItemType.Attribute)
{
sb.Append(">");
hasClosedTag = true;
}
}

await AppendVDom(sb, ownerComponent, vdom, childIndex);

// Skip descendants of children
if (childItem.ItemType == VDomItemType.Element)
{
childIndex = childItem.DescendantsEndIndex;
}
}
if (!hasClosedTag)
{
sb.Append(">");
}
sb.AppendFormat("</{0}>", rootItem.ElementTagName);
break;
case VDomItemType.Attribute:
var attributeValue = GetAttributeStringValue(rootItem);
if (rootItem.AttributeName.Equals("href", StringComparison.OrdinalIgnoreCase) && attributeValue.StartsWith("~/"))
{
// When rendering on server, convert virtual paths to regular URLs
attributeValue = attributeValue.Substring(1);
}
sb.AppendFormat(" {0}=\"{1}\"",
rootItem.AttributeName,
HtmlAttributeEncode(attributeValue));
break;
case VDomItemType.TextNode:
sb.Append(HtmlTextEncode(rootItem.TextNodeContents));
break;
case VDomItemType.Component:
var componentInstance = rootItem.ComponentInstance;
if (componentInstance == null)
{
var childComponent = Interop.Components.InstantiateAndMountComponent(
"ignored", // elementRef
ownerComponent,
rootIndex);
if (childComponent == null)
{
throw new InvalidOperationException("Could not find child component immediately after instantiation.");
}
await AppendComponentHtml(sb, childComponent);
}
else
{
componentInstance.MountAsComponent("ignoredElemRef");
var firstRenderCompletedTask = (Task)typeof(Component).GetProperty("FirstRenderCompletedTask", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(componentInstance);
if (firstRenderCompletedTask != null)
{
await firstRenderCompletedTask;
}
await AppendComponentHtml(sb, componentInstance);
}
break;
}
}

internal static async Task<string> PrerenderPage(string clientRootDir, string clientAppAssemblyName, HttpContext httpContext)
{
// TODO: Obviously there's no need to be reading this on every request
var pageTemplate = File.ReadAllText(Path.Combine(clientRootDir, "wwwroot", "index.html"));

// TODO: Don't reload view assembly on every request. Only do so when changed.
// It's crazily inefficient to keep loading new copies of the same assembly like this.
var viewsAssembly = GetCompiledViewsAssembly(clientRootDir, clientAppAssemblyName, viewReferenceAssemblies);

return pageTemplate.Replace(
"<div id=\"app\">Loading...</div>",
"<div id=\"app\">" + await PrerenderUrl(clientRootDir, httpContext, new[] { viewsAssembly })) + "</div>";
}

private static string GetAttributeStringValue(VDomItem attributeItem)
{
return attributeItem.AttributeStringValue
?? attributeItem.AttributeObjectValue?.ToString()
?? string.Empty; // No need to serialise references to delegates, etc, since we can't call them in prerendering anyway
}

private static string HtmlTextEncode(string textNodeContents)
{
// TODO: Actually encode
return textNodeContents;
}

private static string HtmlAttributeEncode(string attributeValue)
{
// TODO: Actually encode
return attributeValue;
}

private static Component FindComponentById(int id)
{
return (Component)typeof(Component)
.GetMethod("FindById", BindingFlags.Static | BindingFlags.NonPublic)
.Invoke(null, new object[] { id });
}

private static int viewAssemblyCount = 0;
private static Assembly GetCompiledViewsAssembly(string rootPath, string appAssemblyName, string[] referenceAssemblies)
{
var viewsAssemblyName = appAssemblyName.Replace(".dll", $".{ ++viewAssemblyCount }.Views.dll");
var viewAssemblyBytes = RazorCompilation.GetCompiledViewsAssembly(
rootPath,
viewsAssemblyName,
referenceAssemblies);
using (var ms = new MemoryStream(viewAssemblyBytes))
{
return AssemblyLoadContext.Default.LoadFromStream(ms);
}
}
}
}
21 changes: 14 additions & 7 deletions src/Blazor.Host/Host/RazorCompilation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
using System.IO;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Primitives;
using System.Text.RegularExpressions;

namespace Blazor.Sdk.Host
{
Expand Down Expand Up @@ -71,6 +73,14 @@ private static async Task ServeCompiledAssembly(HttpContext context, string root
var assemblyFilename = requestPath.Substring(requestPath.LastIndexOf('/') + 1);
var references = context.Request.Query["reference"];

// Serve the assembly
context.Response.ContentType = "application/octet-steam";
var compiledAssembly = GetCompiledViewsAssembly(rootDir, assemblyFilename, references);
await context.Response.Body.WriteAsync(compiledAssembly, 0, compiledAssembly.Length);
}

internal static byte[] GetCompiledViewsAssembly(string rootDir, string assemblyFilename, IEnumerable<string> references)
{
// Get or create cached compilation result. Doesn't really matter that we might be blocking
// other request threads with this lock, as this is a development-time feature only.
byte[] compiledAssembly;
Expand All @@ -85,9 +95,7 @@ private static async Task ServeCompiledAssembly(HttpContext context, string root
compiledAssembly = cachedCompilationResults[cacheKey];
}

// Actually serve it
context.Response.ContentType = "application/octet-steam";
await context.Response.Body.WriteAsync(compiledAssembly, 0, compiledAssembly.Length);
return compiledAssembly;
}

private static byte[] PerformCompilation(string assemblyFilename, string rootDir, IEnumerable<string> additionalReferenceAssemblies)
Expand Down Expand Up @@ -116,11 +124,10 @@ private static byte[] PerformCompilation(string assemblyFilename, string rootDir

private static string InferMainAssemblyFilename(string viewsAssemblyFilename)
{
const string viewsAssemblySuffix = ".Views.dll";
if (viewsAssemblyFilename.EndsWith(viewsAssemblySuffix))
var partBeforeSuffix = Regex.Match(viewsAssemblyFilename, "(.*)(\\.\\d+)\\.Views\\.dll$");
if (partBeforeSuffix.Success)
{
var partBeforeSuffix = viewsAssemblyFilename.Substring(0, viewsAssemblyFilename.Length - viewsAssemblySuffix.Length);
return $"{partBeforeSuffix}.dll";
return $"{partBeforeSuffix.Groups[1].Value}.dll";
}

return null;
Expand Down
Loading

0 comments on commit e77120f

Please sign in to comment.