From bfc993af507f4b43451282f9d6ce2232fa2829a2 Mon Sep 17 00:00:00 2001 From: SteveSandersonMS Date: Tue, 1 Mar 2016 00:04:51 +0000 Subject: [PATCH] Support loading prerenderer boot module via Webpack config; use this in Angular 2 template --- .../Content/Node/prerenderer.js | 266 +++++++++--------- .../Prerendering/PrerenderTagHelper.cs | 12 +- .../Prerendering/Prerenderer.cs | 19 +- .../Angular2Spa/ClientApp/boot-server.ts | 34 +-- templates/Angular2Spa/Views/Home/Index.cshtml | 3 +- templates/Angular2Spa/package.json | 2 + 6 files changed, 179 insertions(+), 157 deletions(-) diff --git a/src/Microsoft.AspNet.SpaServices/Content/Node/prerenderer.js b/src/Microsoft.AspNet.SpaServices/Content/Node/prerenderer.js index dd5d169..7d74351 100644 --- a/src/Microsoft.AspNet.SpaServices/Content/Node/prerenderer.js +++ b/src/Microsoft.AspNet.SpaServices/Content/Node/prerenderer.js @@ -1,81 +1,65 @@ // ----------------------------------------------------------------------------------------- -// Prepare the Node environment to support loading .jsx/.ts/.tsx files without needing precompilation, -// since that's such a common scenario. In the future, this might become a config option. -// This is bundled in with the actual prerendering logic below just to simplify the initialization -// logic (we can't have cross-file imports, because these files don't exist on disk until the -// StringAsTempFile utility puts them there temporarily). +// Loading via Webpack +// This is optional. You don't have to use Webpack. But if you are doing, it's extremely convenient +// to be able to load your boot module via Webpack compilation, so you can use whatever source language +// you like (e.g., TypeScript), and so that loader plugins (e.g., require('./mystyles.less')) work in +// exactly the same way on the server as you do on the client. +// If you don't use Webpack, then it's up to you to define a plain-JS boot module that in turn loads +// whatever other files you need (e.g., using some other compiler/bundler API, or maybe just having +// already precompiled to plain JS files on disk). +function loadViaWebpackNoCache(webpackConfigPath, modulePath) { + var ExternalsPlugin = require('webpack-externals-plugin'); + var requireFromString = require('require-from-string'); + var MemoryFS = require('memory-fs'); + var webpack = require('webpack'); -// TODO: Consider some general method for checking if you have all the necessary NPM modules installed, -// and if not, giving an error that tells you what command to execute to install the missing ones. -var fs = require('fs'); -var ts = requireIfInstalled('ntypescript'); -var babelCore = require('babel-core'); -var resolveBabelRc = require('babel-loader/lib/resolve-rc'); // If this ever breaks, we can easily scan up the directory hierarchy ourselves -var origJsLoader = require.extensions['.js']; + return new Promise(function(resolve, reject) { + // Load the Webpack config and make alterations needed for loading the output into Node + var webpackConfig = require(webpackConfigPath); + webpackConfig.entry = modulePath; + webpackConfig.target = 'node'; + webpackConfig.output = { path: '/', filename: 'webpack-output.js', libraryTarget: 'commonjs' }; -function resolveBabelOptions(relativeToFilename) { - var babelRcText = resolveBabelRc(relativeToFilename); - try { - return babelRcText ? JSON.parse(babelRcText) : {}; - } catch (ex) { - ex.message = 'Error while parsing babelrc JSON: ' + ex.message; - throw ex; + // In Node, we want anything under /node_modules/ to be loaded natively and not bundled into the output + // (partly because it's faster, but also because otherwise there'd be different instances of modules + // depending on how they were loaded, which could lead to errors) + webpackConfig.plugins = webpackConfig.plugins || []; + webpackConfig.plugins.push(new ExternalsPlugin({ type: 'commonjs', include: /node_modules/ })); + + // The CommonsChunkPlugin is not compatible with a CommonJS environment like Node, nor is it needed in that case + webpackConfig.plugins = webpackConfig.plugins.filter(function(plugin) { + return !(plugin instanceof webpack.optimize.CommonsChunkPlugin); + }); + + // Create a compiler instance that stores its output in memory, then load its output + var compiler = webpack(webpackConfig); + compiler.outputFileSystem = new MemoryFS(); + compiler.run(function(err, stats) { + if (err) { + reject(err); + } else { + var fileContent = compiler.outputFileSystem.readFileSync('/webpack-output.js', 'utf8'); + var moduleInstance = requireFromString(fileContent); + resolve(moduleInstance); + } + }); + }); +} + +// Ensure we only go through the compile process once per [config, module] pair +var loadViaWebpackPromisesCache = {}; +function loadViaWebpack(webpackConfigPath, modulePath, callback) { + var cacheKey = JSON.stringify(webpackConfigPath) + JSON.stringify(modulePath); + if (!(cacheKey in loadViaWebpackPromisesCache)) { + loadViaWebpackPromisesCache[cacheKey] = loadViaWebpackNoCache(webpackConfigPath, modulePath); } + loadViaWebpackPromisesCache[cacheKey].then(function(result) { + callback(null, result); + }, function(error) { + callback(error); + }) } -function loadViaTypeScript(module, filename) { - if (!ts) { - throw new Error('Can\'t load .ts/.tsx files because the \'ntypescript\' package isn\'t installed.\nModule requested: ' + module); - } - - // First perform a minimal transpilation from TS code to ES2015. This is very fast (doesn't involve type checking) - // and is unlikely to need any special compiler options - var src = fs.readFileSync(filename, 'utf8'); - var compilerOptions = { jsx: ts.JsxEmit.Preserve, module: ts.ModuleKind.ES2015, target: ts.ScriptTarget.ES6, emitDecoratorMetadata: true }; - var es6Code = ts.transpile(src, compilerOptions, 'test.tsx', /* diagnostics */ []); - - // Second, process the ES2015 via Babel. We have to do this (instead of going directly from TS to ES5) because - // TypeScript's ES5 output isn't exactly compatible with Node-style CommonJS modules. The main issue is with - // resolving default exports - https://github.com/Microsoft/TypeScript/issues/2719 - var es5Code = babelCore.transform(es6Code, resolveBabelOptions(filename)).code; - return module._compile(es5Code, filename); -} - -function loadViaBabel(module, filename) { - // Assume that all the app's own code is ES2015+ (optionally with JSX), but that none of the node_modules are. - // The distinction is important because ES2015+ forces strict mode, and it may break ES3/5 if you try to run it in strict - // mode when the developer didn't expect that (e.g., current versions of underscore.js can't be loaded in strict mode). - var useBabel = filename.indexOf('node_modules') < 0; - if (useBabel) { - var transformedFile = babelCore.transformFileSync(filename, resolveBabelOptions(filename)); - return module._compile(transformedFile.code, filename); - } else { - return origJsLoader.apply(this, arguments); - } -} - -function requireIfInstalled(packageName) { - return isPackageInstalled(packageName) ? require(packageName) : null; -} - -function isPackageInstalled(packageName) { - try { - require.resolve(packageName); - return true; - } catch(e) { - return false; - } -} - -function register() { - require.extensions['.js'] = loadViaBabel; - require.extensions['.jsx'] = loadViaBabel; - require.extensions['.ts'] = loadViaTypeScript; - require.extensions['.tsx'] = loadViaTypeScript; -}; - -register(); - // ----------------------------------------------------------------------------------------- // Rendering @@ -85,64 +69,94 @@ var domain = require('domain'); var domainTask = require('domain-task'); var baseUrl = require('domain-task/fetch').baseUrl; -function findBootFunc(bootModulePath, bootModuleExport) { - var resolvedPath = path.resolve(process.cwd(), bootModulePath); - var bootFunc = require(resolvedPath); - if (bootModuleExport) { - bootFunc = bootFunc[bootModuleExport]; - } else if (typeof bootFunc !== 'function') { - bootFunc = bootFunc.default; // TypeScript sometimes uses this name for default exports +function findBootModule(bootModule, callback) { + var bootModuleNameFullPath = path.resolve(process.cwd(), bootModule.moduleName); + if (bootModule.webpackConfig) { + var webpackConfigFullPath = path.resolve(process.cwd(), bootModule.webpackConfig); + loadViaWebpack(webpackConfigFullPath, bootModuleNameFullPath, callback); + } else { + callback(null, require(bootModuleNameFullPath)); } - if (typeof bootFunc !== 'function') { - if (bootModuleExport) { - throw new Error('The module at ' + bootModulePath + ' has no function export named ' + bootModuleExport + '.'); - } else { - throw new Error('The module at ' + bootModulePath + ' does not export a default function, and you have not specified which export to invoke.'); - } - } - - return bootFunc; } -function renderToString(callback, bootModulePath, bootModuleExport, absoluteRequestUrl, requestPathAndQuery) { - var bootFunc = findBootFunc(bootModulePath, bootModuleExport); - - // Prepare a promise that will represent the completion of all domain tasks in this execution context. - // The boot code will wait for this before performing its final render. - var domainTaskCompletionPromiseResolve; - var domainTaskCompletionPromise = new Promise(function (resolve, reject) { - domainTaskCompletionPromiseResolve = resolve; - }); - var params = { - location: url.parse(requestPathAndQuery), - url: requestPathAndQuery, - absoluteUrl: absoluteRequestUrl, - domainTasks: domainTaskCompletionPromise - }; - - // Open a new domain that can track all the async tasks involved in the app's execution - domainTask.run(function() { - // Workaround for Node bug where native Promise continuations lose their domain context - // (https://github.com/nodejs/node-v0.x-archive/issues/8648) - bindPromiseContinuationsToDomain(domainTaskCompletionPromise, domain.active); - - // Make the base URL available to the 'domain-tasks/fetch' helper within this execution context - baseUrl(absoluteRequestUrl); - - // Actually perform the rendering - bootFunc(params).then(function(successResult) { - callback(null, { html: successResult.html, globals: successResult.globals }); - }, function(error) { - callback(error, null); - }); - }, function allDomainTasksCompleted(error) { - // There are no more ongoing domain tasks (typically data access operations), so we can resolve - // the domain tasks promise which notifies the boot code that it can do its final render. - if (error) { - callback(error, null); - } else { - domainTaskCompletionPromiseResolve(); +function findBootFunc(bootModule, callback) { + // First try to load the module (possibly via Webpack) + findBootModule(bootModule, function(findBootModuleError, foundBootModule) { + if (findBootModuleError) { + callback(findBootModuleError); + return; } + + // Now try to pick out the function they want us to invoke + var bootFunc; + if (bootModule.exportName) { + // Explicitly-named export + bootFunc = foundBootModule[bootModule.exportName]; + } else if (typeof foundBootModule !== 'function') { + // TypeScript-style default export + bootFunc = foundBootModule.default; + } else { + // Native default export + bootFunc = foundBootModule; + } + + // Validate the result + if (typeof bootFunc !== 'function') { + if (bootModule.exportName) { + callback(new Error('The module at ' + bootModule.moduleName + ' has no function export named ' + bootModule.exportName + '.')); + } else { + callback(new Error('The module at ' + bootModule.moduleName + ' does not export a default function, and you have not specified which export to invoke.')); + } + } else { + callback(null, bootFunc); + } + }); +} + +function renderToString(callback, bootModule, absoluteRequestUrl, requestPathAndQuery) { + findBootFunc(bootModule, function (findBootFuncError, bootFunc) { + if (findBootFuncError) { + callback(findBootFuncError); + return; + } + + // Prepare a promise that will represent the completion of all domain tasks in this execution context. + // The boot code will wait for this before performing its final render. + var domainTaskCompletionPromiseResolve; + var domainTaskCompletionPromise = new Promise(function (resolve, reject) { + domainTaskCompletionPromiseResolve = resolve; + }); + var params = { + location: url.parse(requestPathAndQuery), + url: requestPathAndQuery, + absoluteUrl: absoluteRequestUrl, + domainTasks: domainTaskCompletionPromise + }; + + // Open a new domain that can track all the async tasks involved in the app's execution + domainTask.run(function() { + // Workaround for Node bug where native Promise continuations lose their domain context + // (https://github.com/nodejs/node-v0.x-archive/issues/8648) + bindPromiseContinuationsToDomain(domainTaskCompletionPromise, domain.active); + + // Make the base URL available to the 'domain-tasks/fetch' helper within this execution context + baseUrl(absoluteRequestUrl); + + // Actually perform the rendering + bootFunc(params).then(function(successResult) { + callback(null, { html: successResult.html, globals: successResult.globals }); + }, function(error) { + callback(error, null); + }); + }, function allDomainTasksCompleted(error) { + // There are no more ongoing domain tasks (typically data access operations), so we can resolve + // the domain tasks promise which notifies the boot code that it can do its final render. + if (error) { + callback(error, null); + } else { + domainTaskCompletionPromiseResolve(); + } + }); }); } diff --git a/src/Microsoft.AspNet.SpaServices/Prerendering/PrerenderTagHelper.cs b/src/Microsoft.AspNet.SpaServices/Prerendering/PrerenderTagHelper.cs index a81c415..236d053 100644 --- a/src/Microsoft.AspNet.SpaServices/Prerendering/PrerenderTagHelper.cs +++ b/src/Microsoft.AspNet.SpaServices/Prerendering/PrerenderTagHelper.cs @@ -17,12 +17,16 @@ namespace Microsoft.AspNet.SpaServices.Prerendering const string PrerenderModuleAttributeName = "asp-prerender-module"; const string PrerenderExportAttributeName = "asp-prerender-export"; + const string PrerenderWebpackConfigAttributeName = "asp-prerender-webpack-config"; [HtmlAttributeName(PrerenderModuleAttributeName)] public string ModuleName { get; set; } [HtmlAttributeName(PrerenderExportAttributeName)] public string ExportName { get; set; } + + [HtmlAttributeName(PrerenderWebpackConfigAttributeName)] + public string WebpackConfigPath { get; set; } private IHttpContextAccessor contextAccessor; private INodeServices nodeServices; @@ -48,13 +52,15 @@ namespace Microsoft.AspNet.SpaServices.Prerendering var request = this.contextAccessor.HttpContext.Request; var result = await Prerenderer.RenderToString( nodeServices: this.nodeServices, - componentModuleName: this.ModuleName, - componentExportName: this.ExportName, + bootModule: new JavaScriptModuleExport(this.ModuleName) { + exportName = this.ExportName, + webpackConfig = this.WebpackConfigPath + }, requestAbsoluteUrl: UriHelper.GetEncodedUrl(this.contextAccessor.HttpContext.Request), requestPathAndQuery: request.Path + request.QueryString.Value); output.Content.SetHtmlContent(result.Html); - // Also attach any specific globals to the 'window' object. This is useful for transferring + // 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 stringBuilder = new StringBuilder(); diff --git a/src/Microsoft.AspNet.SpaServices/Prerendering/Prerenderer.cs b/src/Microsoft.AspNet.SpaServices/Prerendering/Prerenderer.cs index b7558d4..d06ec95 100644 --- a/src/Microsoft.AspNet.SpaServices/Prerendering/Prerenderer.cs +++ b/src/Microsoft.AspNet.SpaServices/Prerendering/Prerenderer.cs @@ -16,12 +16,21 @@ namespace Microsoft.AspNet.SpaServices.Prerendering }); } - public static async Task RenderToString(INodeServices nodeServices, string componentModuleName, string componentExportName, string requestAbsoluteUrl, string requestPathAndQuery) { + public static async Task RenderToString(INodeServices nodeServices, JavaScriptModuleExport bootModule, string requestAbsoluteUrl, string requestPathAndQuery) { return await nodeServices.InvokeExport(nodeScript.Value.FileName, "renderToString", - /* bootModulePath */ componentModuleName, - /* bootModuleExport */ componentExportName, - /* absoluteRequestUrl */ requestAbsoluteUrl, - /* requestPathAndQuery */ requestPathAndQuery); + bootModule, + requestAbsoluteUrl, + requestPathAndQuery); + } + } + + public class JavaScriptModuleExport { + public string moduleName { get; private set; } + public string exportName { get; set; } + public string webpackConfig { get; set; } + + public JavaScriptModuleExport(string moduleName) { + this.moduleName = moduleName; } } diff --git a/templates/Angular2Spa/ClientApp/boot-server.ts b/templates/Angular2Spa/ClientApp/boot-server.ts index 4f6c591..32e5c6e 100644 --- a/templates/Angular2Spa/ClientApp/boot-server.ts +++ b/templates/Angular2Spa/ClientApp/boot-server.ts @@ -4,29 +4,19 @@ import * as ngRouter from 'angular2/router'; import * as ngUniversal from 'angular2-universal-preview'; import { BASE_URL } from 'angular2-universal-preview/dist/server/src/http/node_http'; import * as ngUniversalRender from 'angular2-universal-preview/dist/server/src/render'; +import { App } from './components/app/app'; -// TODO: Make this ugly code go away, e.g., by somehow loading via Webpack -function loadAsString(module, filename) { - module.exports = require('fs').readFileSync(filename, 'utf8'); -} -(require as any).extensions['.html'] = loadAsString; -(require as any).extensions['.css'] = loadAsString; -let App: any = require('./components/app/app').App; +export default function (params: any): Promise<{ html: string, globals?: any }> { + const serverBindings = [ + ngRouter.ROUTER_BINDINGS, + ngUniversal.HTTP_PROVIDERS, + ngCore.provide(BASE_URL, { useValue: params.absoluteUrl }), + ngCore.provide(ngUniversal.REQUEST_URL, { useValue: params.url }), + ngCore.provide(ngRouter.APP_BASE_HREF, { useValue: '/' }), + ngUniversal.SERVER_LOCATION_PROVIDERS + ]; -export default function (params: any): Promise<{ html: string }> { - return new Promise<{ html: string, globals: { [key: string]: any } }>((resolve, reject) => { - const serverBindings = [ - ngRouter.ROUTER_BINDINGS, - ngUniversal.HTTP_PROVIDERS, - ngCore.provide(BASE_URL, { useValue: params.absoluteUrl }), - ngCore.provide(ngUniversal.REQUEST_URL, { useValue: params.url }), - ngCore.provide(ngRouter.APP_BASE_HREF, { useValue: '/' }), - ngUniversal.SERVER_LOCATION_PROVIDERS - ]; - - ngUniversalRender.renderToString(App, serverBindings).then( - html => resolve({ html, globals: {} }), - reject // Also propagate any errors back into the host application - ); + return ngUniversalRender.renderToString(App, serverBindings).then(html => { + return { html, globals: {}}; }); } diff --git a/templates/Angular2Spa/Views/Home/Index.cshtml b/templates/Angular2Spa/Views/Home/Index.cshtml index 82ab91b..2f99cad 100644 --- a/templates/Angular2Spa/Views/Home/Index.cshtml +++ b/templates/Angular2Spa/Views/Home/Index.cshtml @@ -2,7 +2,8 @@ ViewData["Title"] = "Home Page"; } -Loading... +Loading... @section scripts { diff --git a/templates/Angular2Spa/package.json b/templates/Angular2Spa/package.json index bd379d7..e41eb83 100644 --- a/templates/Angular2Spa/package.json +++ b/templates/Angular2Spa/package.json @@ -35,7 +35,9 @@ "parse5": "^1.5.1", "preboot": "^1.1.3", "reflect-metadata": "^0.1.2", + "require-from-string": "^1.1.0", "rxjs": "^5.0.0-beta.2", + "webpack-externals-plugin": "^1.0.0", "zone.js": "^0.5.15" } }