diff --git a/src/Microsoft.AspNetCore.SpaServices/Content/Node/prerenderer.js b/src/Microsoft.AspNetCore.SpaServices/Content/Node/prerenderer.js index d5ebd7e..4be014b 100644 --- a/src/Microsoft.AspNetCore.SpaServices/Content/Node/prerenderer.js +++ b/src/Microsoft.AspNetCore.SpaServices/Content/Node/prerenderer.js @@ -52,29 +52,105 @@ /***/ function(module, exports, __webpack_require__) { "use strict"; - // Pass through the invocation to the 'aspnet-prerendering' package, verifying that it can be loaded - function renderToString(callback) { - var aspNetPrerendering; + var path = __webpack_require__(2); + // Separate declaration and export just to add type checking on function signature + exports.renderToString = renderToStringImpl; + // This function is invoked by .NET code (via NodeServices). Its job is to hand off execution to the application's + // prerendering boot function. It can operate in two modes: + // [1] Legacy mode + // This is for backward compatibility with projects created with templates older than the generator version 0.6.0. + // In this mode, we don't really do anything here - we just load the 'aspnet-prerendering' NPM module (which must + // exist in node_modules, and must be v1.x (not v2+)), and pass through all the parameters to it. Code in + // 'aspnet-prerendering' v1.x will locate the boot function and invoke it. + // The drawback to this mode is that, for it to work, you have to deploy node_modules to production. + // [2] Current mode + // This is for projects created with the Yeoman generator 0.6.0+ (or projects manually updated). In this mode, + // we don't invoke 'require' at runtime at all. All our dependencies are bundled into the NuGet package, so you + // don't have to deploy node_modules to production. + // To determine whether we're in mode [1] or [2], the code locates your prerendering boot function, and checks whether + // a certain flag is attached to the function instance. + function renderToStringImpl(callback, applicationBasePath, bootModule, absoluteRequestUrl, requestPathAndQuery, customDataParameter, overrideTimeoutMilliseconds) { try { - aspNetPrerendering = __webpack_require__(2); + var renderToStringFunc = findRenderToStringFunc(applicationBasePath, bootModule); + var isNotLegacyMode = renderToStringFunc && renderToStringFunc['isServerRenderer']; + if (isNotLegacyMode) { + // Current (non-legacy) mode - we invoke the exported function directly (instead of going through aspnet-prerendering) + // It's type-safe to just apply the incoming args to this function, because we already type-checked that it's a RenderToStringFunc, + // just like renderToStringImpl itself is. + renderToStringFunc.apply(null, arguments); + } + else { + // Legacy mode - just hand off execution to 'aspnet-prerendering' v1.x, which must exist in node_modules at runtime + renderToStringFunc = __webpack_require__(3).renderToString; + if (renderToStringFunc) { + renderToStringFunc(callback, applicationBasePath, bootModule, absoluteRequestUrl, requestPathAndQuery, customDataParameter, overrideTimeoutMilliseconds); + } + else { + callback('If you use aspnet-prerendering >= 2.0.0, you must update your server-side boot module to call createServerRenderer. ' + + 'Either update your boot module code, or revert to aspnet-prerendering version 1.x'); + } + } } catch (ex) { - // Developers sometimes have trouble with badly-configured Node installations, where it's unable - // to find node_modules. Or they accidentally fail to deploy node_modules, or even to run 'npm install'. - // Make sure such errors are reported back to the .NET part of the app. - callback('Prerendering failed because of an error while loading \'aspnet-prerendering\'. Error was: ' + // Make sure loading errors are reported back to the .NET part of the app + callback('Prerendering failed because of error: ' + ex.stack + '\nCurrent directory is: ' + process.cwd()); - return; } - return aspNetPrerendering.renderToString.apply(this, arguments); } - exports.renderToString = renderToString; + ; + function findBootModule(applicationBasePath, bootModule) { + var bootModuleNameFullPath = path.resolve(applicationBasePath, bootModule.moduleName); + if (bootModule.webpackConfig) { + // If you're using asp-prerender-webpack-config, you're definitely in legacy mode + return null; + } + else { + return require(bootModuleNameFullPath); + } + } + function findRenderToStringFunc(applicationBasePath, bootModule) { + // First try to load the module + var foundBootModule = findBootModule(applicationBasePath, bootModule); + if (foundBootModule === null) { + return null; // Must be legacy mode + } + // Now try to pick out the function they want us to invoke + var renderToStringFunc; + if (bootModule.exportName) { + // Explicitly-named export + renderToStringFunc = foundBootModule[bootModule.exportName]; + } + else if (typeof foundBootModule !== 'function') { + // TypeScript-style default export + renderToStringFunc = foundBootModule.default; + } + else { + // Native default export + renderToStringFunc = foundBootModule; + } + // Validate the result + if (typeof renderToStringFunc !== 'function') { + if (bootModule.exportName) { + throw new Error("The module at " + bootModule.moduleName + " has no function export named " + bootModule.exportName + "."); + } + else { + throw new Error("The module at " + bootModule.moduleName + " does not export a default function, and you have not specified which export to invoke."); + } + } + return renderToStringFunc; + } /***/ }, /* 2 */ +/***/ function(module, exports) { + + module.exports = require("path"); + +/***/ }, +/* 3 */ /***/ function(module, exports) { module.exports = require("aspnet-prerendering"); diff --git a/src/Microsoft.AspNetCore.SpaServices/Content/Node/webpack-dev-middleware.js b/src/Microsoft.AspNetCore.SpaServices/Content/Node/webpack-dev-middleware.js index dbabebe..f5385d4 100644 --- a/src/Microsoft.AspNetCore.SpaServices/Content/Node/webpack-dev-middleware.js +++ b/src/Microsoft.AspNetCore.SpaServices/Content/Node/webpack-dev-middleware.js @@ -44,13 +44,14 @@ /* 0 */ /***/ function(module, exports, __webpack_require__) { - module.exports = __webpack_require__(3); + module.exports = __webpack_require__(4); /***/ }, /* 1 */, /* 2 */, -/* 3 */ +/* 3 */, +/* 4 */ /***/ function(module, exports, __webpack_require__) { "use strict"; @@ -58,7 +59,7 @@ function createWebpackDevServer(callback) { var aspNetWebpack; try { - aspNetWebpack = __webpack_require__(4); + aspNetWebpack = __webpack_require__(5); } catch (ex) { // Developers sometimes have trouble with badly-configured Node installations, where it's unable @@ -76,7 +77,7 @@ /***/ }, -/* 4 */ +/* 5 */ /***/ function(module, exports) { module.exports = require("aspnet-webpack"); diff --git a/src/Microsoft.AspNetCore.SpaServices/TypeScript/Prerenderer.ts b/src/Microsoft.AspNetCore.SpaServices/TypeScript/Prerenderer.ts index a837f98..3b6d5d7 100644 --- a/src/Microsoft.AspNetCore.SpaServices/TypeScript/Prerenderer.ts +++ b/src/Microsoft.AspNetCore.SpaServices/TypeScript/Prerenderer.ts @@ -1,20 +1,94 @@ -// Pass through the invocation to the 'aspnet-prerendering' package, verifying that it can be loaded -export function renderToString(callback) { - let aspNetPrerendering; +/// +import * as url from 'url'; +import * as path from 'path'; +declare var __non_webpack_require__; + +// Separate declaration and export just to add type checking on function signature +export const renderToString: RenderToStringFunc = renderToStringImpl; + +// This function is invoked by .NET code (via NodeServices). Its job is to hand off execution to the application's +// prerendering boot function. It can operate in two modes: +// [1] Legacy mode +// This is for backward compatibility with projects created with templates older than the generator version 0.6.0. +// In this mode, we don't really do anything here - we just load the 'aspnet-prerendering' NPM module (which must +// exist in node_modules, and must be v1.x (not v2+)), and pass through all the parameters to it. Code in +// 'aspnet-prerendering' v1.x will locate the boot function and invoke it. +// The drawback to this mode is that, for it to work, you have to deploy node_modules to production. +// [2] Current mode +// This is for projects created with the Yeoman generator 0.6.0+ (or projects manually updated). In this mode, +// we don't invoke 'require' at runtime at all. All our dependencies are bundled into the NuGet package, so you +// don't have to deploy node_modules to production. +// To determine whether we're in mode [1] or [2], the code locates your prerendering boot function, and checks whether +// a certain flag is attached to the function instance. +function renderToStringImpl(callback: RenderToStringCallback, applicationBasePath: string, bootModule: BootModuleInfo, absoluteRequestUrl: string, requestPathAndQuery: string, customDataParameter: any, overrideTimeoutMilliseconds: number) { try { - aspNetPrerendering = require('aspnet-prerendering'); + let renderToStringFunc = findRenderToStringFunc(applicationBasePath, bootModule); + const isNotLegacyMode = renderToStringFunc && renderToStringFunc['isServerRenderer']; + + if (isNotLegacyMode) { + // Current (non-legacy) mode - we invoke the exported function directly (instead of going through aspnet-prerendering) + // It's type-safe to just apply the incoming args to this function, because we already type-checked that it's a RenderToStringFunc, + // just like renderToStringImpl itself is. + renderToStringFunc.apply(null, arguments); + } else { + // Legacy mode - just hand off execution to 'aspnet-prerendering' v1.x, which must exist in node_modules at runtime + renderToStringFunc = require('aspnet-prerendering').renderToString; + if (renderToStringFunc) { + renderToStringFunc(callback, applicationBasePath, bootModule, absoluteRequestUrl, requestPathAndQuery, customDataParameter, overrideTimeoutMilliseconds); + } else { + callback('If you use aspnet-prerendering >= 2.0.0, you must update your server-side boot module to call createServerRenderer. ' + + 'Either update your boot module code, or revert to aspnet-prerendering version 1.x'); + } + } } catch (ex) { - // Developers sometimes have trouble with badly-configured Node installations, where it's unable - // to find node_modules. Or they accidentally fail to deploy node_modules, or even to run 'npm install'. - // Make sure such errors are reported back to the .NET part of the app. + // Make sure loading errors are reported back to the .NET part of the app callback( - 'Prerendering failed because of an error while loading \'aspnet-prerendering\'. Error was: ' + 'Prerendering failed because of error: ' + ex.stack + '\nCurrent directory is: ' + process.cwd() ); - return; + } +}; + +function findBootModule(applicationBasePath: string, bootModule: BootModuleInfo): any { + const bootModuleNameFullPath = path.resolve(applicationBasePath, bootModule.moduleName); + if (bootModule.webpackConfig) { + // If you're using asp-prerender-webpack-config, you're definitely in legacy mode + return null; + } else { + return __non_webpack_require__(bootModuleNameFullPath); + } +} + +function findRenderToStringFunc(applicationBasePath: string, bootModule: BootModuleInfo): RenderToStringFunc { + // First try to load the module + const foundBootModule = findBootModule(applicationBasePath, bootModule); + if (foundBootModule === null) { + return null; // Must be legacy mode } - return aspNetPrerendering.renderToString.apply(this, arguments); + // Now try to pick out the function they want us to invoke + let renderToStringFunc: RenderToStringFunc; + if (bootModule.exportName) { + // Explicitly-named export + renderToStringFunc = foundBootModule[bootModule.exportName]; + } else if (typeof foundBootModule !== 'function') { + // TypeScript-style default export + renderToStringFunc = foundBootModule.default; + } else { + // Native default export + renderToStringFunc = foundBootModule; + } + + // Validate the result + if (typeof renderToStringFunc !== 'function') { + if (bootModule.exportName) { + throw new Error(`The module at ${ bootModule.moduleName } has no function export named ${ bootModule.exportName }.`); + } else { + throw new Error(`The module at ${ bootModule.moduleName } does not export a default function, and you have not specified which export to invoke.`); + } + } + + return renderToStringFunc; } diff --git a/src/Microsoft.AspNetCore.SpaServices/TypeScript/tsconfig.json b/src/Microsoft.AspNetCore.SpaServices/TypeScript/tsconfig.json index 896fc88..433cde0 100644 --- a/src/Microsoft.AspNetCore.SpaServices/TypeScript/tsconfig.json +++ b/src/Microsoft.AspNetCore.SpaServices/TypeScript/tsconfig.json @@ -3,7 +3,8 @@ "target": "es3", "module": "commonjs", "moduleResolution": "node", - "types": ["node"] + "types": ["node"], + "lib": ["es2015"] }, "exclude": [ "node_modules" diff --git a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/.gitignore b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/.gitignore index a1df9a5..477bc9d 100644 --- a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/.gitignore +++ b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/.gitignore @@ -1,4 +1,6 @@ /typings/ /node_modules/ -/*.js -/*.d.ts +/**/*.js + +/**/.d.ts +!/src/**/*.d.ts diff --git a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/package.json b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/package.json index 83e0259..301d6a2 100644 --- a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/package.json +++ b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/package.json @@ -1,6 +1,6 @@ { "name": "aspnet-prerendering", - "version": "1.0.7", + "version": "2.0.0", "description": "Helpers for server-side rendering of JavaScript applications in ASP.NET Core projects. Works in conjunction with the Microsoft.AspNetCore.SpaServices NuGet package.", "main": "index.js", "scripts": { @@ -17,8 +17,7 @@ "url": "https://github.com/aspnet/JavaScriptServices.git" }, "dependencies": { - "domain-task": "^2.0.1", - "es6-promise": "^3.1.2" + "domain-task": "^2.0.1" }, "devDependencies": { "@types/node": "^6.0.42", diff --git a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/src/Prerendering.ts b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/src/Prerendering.ts index e9d604e..9afd211 100644 --- a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/src/Prerendering.ts +++ b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/src/Prerendering.ts @@ -1,4 +1,4 @@ -import 'es6-promise'; +/// import * as url from 'url'; import * as path from 'path'; import * as domain from 'domain'; @@ -7,41 +7,8 @@ import { baseUrl } from 'domain-task/fetch'; const defaultTimeoutMilliseconds = 30 * 1000; -export interface RenderToStringCallback { - (error: any, result: RenderToStringResult): void; -} - -export interface RenderToStringResult { - html: string; - globals: { [key: string]: any }; -} - -export interface BootFunc { - (params: BootFuncParams): Promise; -} - -export interface BootFuncParams { - location: url.Url; // e.g., Location object containing information '/some/path' - origin: string; // e.g., 'https://example.com:1234' - url: string; // e.g., '/some/path' - absoluteUrl: string; // e.g., 'https://example.com:1234/some/path' - domainTasks: Promise; - data: any; // any custom object passed through from .NET -} - -export interface BootModuleInfo { - moduleName: string; - exportName?: string; - webpackConfig?: string; -} - -export function renderToString(callback: RenderToStringCallback, applicationBasePath: string, bootModule: BootModuleInfo, absoluteRequestUrl: string, requestPathAndQuery: string, customDataParameter: any, overrideTimeoutMilliseconds: number) { - findBootFunc(applicationBasePath, bootModule, (findBootFuncError, bootFunc) => { - if (findBootFuncError) { - callback(findBootFuncError, null); - return; - } - +export function createServerRenderer(bootFunc: BootFunc): RenderToStringFunc { + const resultFunc = (callback: RenderToStringCallback, applicationBasePath: string, bootModule: BootModuleInfo, absoluteRequestUrl: string, requestPathAndQuery: string, customDataParameter: any, overrideTimeoutMilliseconds: number) => { // 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. let domainTaskCompletionPromiseResolve; @@ -76,7 +43,7 @@ export function renderToString(callback: RenderToStringCallback, applicationBase } const timeoutMilliseconds = overrideTimeoutMilliseconds || defaultTimeoutMilliseconds; // e.g., pass -1 to override as 'never time out' const bootFuncPromiseWithTimeout = timeoutMilliseconds > 0 - ? wrapWithTimeout(bootFuncPromise, timeoutMilliseconds, + ? wrapWithTimeout(bootFuncPromise, timeoutMilliseconds, `Prerendering timed out after ${timeoutMilliseconds}ms because the boot function in '${bootModule.moduleName}' ` + 'returned a promise that did not resolve or reject. Make sure that your boot function always resolves or ' + 'rejects its promise. You can change the timeout value using the \'asp-prerender-timeout\' tag helper.') @@ -97,7 +64,14 @@ export function renderToString(callback: RenderToStringCallback, applicationBase domainTaskCompletionPromiseResolve(); } }); - }); + }; + + // Indicate to the prerendering code bundled into Microsoft.AspNetCore.SpaServices that this is a serverside rendering + // function, so it can be invoked directly. This flag exists only so that, in its absence, we can run some different + // backward-compatibility logic. + resultFunc['isServerRenderer'] = true; + + return resultFunc; } function wrapWithTimeout(promise: Promise, timeoutMilliseconds: number, timeoutRejectionValue: any): Promise { @@ -119,59 +93,6 @@ function wrapWithTimeout(promise: Promise, timeoutMilliseconds: number, ti }); } -function findBootModule(applicationBasePath: string, bootModule: BootModuleInfo, callback: (error: any, foundModule: T) => void) { - const bootModuleNameFullPath = path.resolve(applicationBasePath, bootModule.moduleName); - if (bootModule.webpackConfig) { - const webpackConfigFullPath = path.resolve(applicationBasePath, bootModule.webpackConfig); - - let aspNetWebpackModule: any; - try { - aspNetWebpackModule = require('aspnet-webpack'); - } catch (ex) { - callback('To load your boot module via webpack (i.e., if you specify a \'webpackConfig\' option), you must install the \'aspnet-webpack\' NPM package. Error encountered while loading \'aspnet-webpack\': ' + ex.stack, null); - return; - } - - aspNetWebpackModule.loadViaWebpack(webpackConfigFullPath, bootModuleNameFullPath, callback); - } else { - callback(null, require(bootModuleNameFullPath)); - } -} - -function findBootFunc(applicationBasePath: string, bootModule: BootModuleInfo, callback: (error: any, bootFunc: BootFunc) => void) { - // First try to load the module (possibly via Webpack) - findBootModule(applicationBasePath, bootModule, (findBootModuleError, foundBootModule) => { - if (findBootModuleError) { - callback(findBootModuleError, null); - return; - } - - // Now try to pick out the function they want us to invoke - let bootFunc: 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(`The module at ${ bootModule.moduleName } has no function export named ${ bootModule.exportName }.`, null); - } else { - callback(`The module at ${ bootModule.moduleName } does not export a default function, and you have not specified which export to invoke.`, null); - } - } else { - callback(null, bootFunc); - } - }); -} - function bindPromiseContinuationsToDomain(promise: Promise, domainInstance: domain.Domain) { const originalThen = promise.then; promise.then = (function then(resolve, reject) { diff --git a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/src/PrerenderingInterfaces.d.ts b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/src/PrerenderingInterfaces.d.ts new file mode 100644 index 0000000..c919646 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/src/PrerenderingInterfaces.d.ts @@ -0,0 +1,35 @@ +interface RenderToStringFunc { + (callback: RenderToStringCallback, applicationBasePath: string, bootModule: BootModuleInfo, absoluteRequestUrl: string, requestPathAndQuery: string, customDataParameter: any, overrideTimeoutMilliseconds: number): void; +} + +interface RenderToStringCallback { + (error: any, result?: RenderToStringResult): void; +} + +interface RenderToStringResult { + html: string; + globals?: { [key: string]: any }; +} + +interface RedirectResult { + redirectUrl: string; +} + +interface BootFunc { + (params: BootFuncParams): Promise; +} + +interface BootFuncParams { + location: any; // e.g., Location object containing information '/some/path' + origin: string; // e.g., 'https://example.com:1234' + url: string; // e.g., '/some/path' + absoluteUrl: string; // e.g., 'https://example.com:1234/some/path' + domainTasks: Promise; + data: any; // any custom object passed through from .NET +} + +interface BootModuleInfo { + moduleName: string; + exportName?: string; + webpackConfig?: string; +} diff --git a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/src/index.ts b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/src/index.ts index aaff576..6df3ad2 100644 --- a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/src/index.ts +++ b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/src/index.ts @@ -1 +1,5 @@ +/// + export * from './Prerendering'; + +export type RenderResult = RenderToStringResult | RedirectResult;