Skip to content

Commit 6c903f3

Browse files
Move React server-side rendering into more general SpaServices package
1 parent b35ac19 commit 6c903f3

File tree

16 files changed

+225
-159
lines changed

16 files changed

+225
-159
lines changed

samples/react/MusicStore/ReactApp/boot-server.tsx

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,38 @@ import { Provider } from 'react-redux';
33
import { renderToString } from 'react-dom/server';
44
import { match, RouterContext } from 'react-router';
55
import createMemoryHistory from 'history/lib/createMemoryHistory';
6-
React;
7-
86
import { routes } from './routes';
97
import configureStore from './configureStore';
10-
import { ApplicationState } from './store';
8+
React;
119

12-
export default function (params: any, callback: (err: any, result: { html: string, state: any }) => void) {
13-
const { location } = params;
14-
match({ routes, location }, (error, redirectLocation, renderProps: any) => {
15-
try {
10+
export default function (params: any): Promise<{ html: string }> {
11+
return new Promise<{ html: string, globals: { [key: string]: any } }>((resolve, reject) => {
12+
// Match the incoming request against the list of client-side routes
13+
match({ routes, location: params.location }, (error, redirectLocation, renderProps: any) => {
1614
if (error) {
1715
throw error;
1816
}
1917

18+
// Build an instance of the application
2019
const history = createMemoryHistory(params.url);
21-
const store = params.state as Redux.Store || configureStore(history);
22-
let html = renderToString(
20+
const store = configureStore(history);
21+
const app = (
2322
<Provider store={ store }>
2423
<RouterContext {...renderProps} />
25-
</Provider>
24+
</Provider>
2625
);
27-
28-
// Also serialise the Redux state so the client can pick up where the server left off
29-
html += `<script>window.__redux_state = ${ JSON.stringify(store.getState()) }</script>`;
30-
31-
callback(null, { html, state: store });
32-
} catch (error) {
33-
callback(error, null);
34-
}
26+
27+
// Perform an initial render that will cause any async tasks (e.g., data access) to begin
28+
renderToString(app);
29+
30+
// Once the tasks are done, we can perform the final render
31+
// We also send the redux store state, so the client can continue execution where the server left off
32+
params.domainTasks.then(() => {
33+
resolve({
34+
html: renderToString(app),
35+
globals: { initialReduxState: store.getState() }
36+
});
37+
}, reject); // Also propagate any errors back into the host application
38+
});
3539
});
3640
}

samples/react/MusicStore/ReactApp/boot.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import './styles/styles.css';
88
import 'bootstrap/dist/css/bootstrap.css';
99
import configureStore from './configureStore';
1010
import { routes } from './routes';
11+
import { ApplicationState } from './store';
1112

12-
const store = configureStore(browserHistory);
13+
const initialState = (window as any).initialReduxState as ApplicationState;
14+
const store = configureStore(browserHistory, initialState);
1315

1416
ReactDOM.render(
1517
<Provider store={ store }>

samples/react/MusicStore/ReactApp/fx/render-server.js

Lines changed: 0 additions & 54 deletions
This file was deleted.

samples/react/MusicStore/ReactApp/fx/require-ts-babel.js

Lines changed: 0 additions & 44 deletions
This file was deleted.

samples/react/MusicStore/Startup.cs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
1-
using System;
2-
using System.Collections.Generic;
3-
using System.Linq;
4-
using System.Threading.Tasks;
51
using AutoMapper;
62
using Microsoft.AspNet.Authorization;
73
using Microsoft.AspNet.Builder;
84
using Microsoft.AspNet.Hosting;
95
using Microsoft.AspNet.Identity.EntityFramework;
10-
using Microsoft.AspNet.SpaServices;
6+
using Microsoft.AspNet.SpaServices.Webpack;
117
using Microsoft.Data.Entity;
128
using Microsoft.Extensions.Configuration;
139
using Microsoft.Extensions.DependencyInjection;

samples/react/MusicStore/Views/Home/Index.cshtml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
ViewData["Title"] = "Home Page";
33
}
44

5-
<div id="react-app">Loading...</div>
5+
<div id="react-app" asp-prerender-module="ReactApp/boot-server"></div>
66

77
@section scripts {
88
<script src="/dist/vendor.bundle.js"></script>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
@using MusicStore
22
@addTagHelper "*, Microsoft.AspNet.Mvc.TagHelpers"
3+
@addTagHelper "*, Microsoft.AspNet.SpaServices"

src/Microsoft.AspNet.NodeServices/Content/Node/entrypoint-http.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ var http = require('http');
44
var path = require('path');
55
var requestedPortOrZero = parseInt(process.argv[2]) || 0; // 0 means 'let the OS decide'
66

7-
autoQuitOnFileChange(process.cwd(), ['.js', '.json', '.html']);
7+
autoQuitOnFileChange(process.cwd(), ['.js', '.jsx', '.ts', '.tsx', '.json', '.html']);
88

99
var server = http.createServer(function(req, res) {
1010
readRequestBodyAsJson(req, function(bodyJson) {

src/Microsoft.AspNet.ReactServices/ReactRenderer.cs

Lines changed: 0 additions & 24 deletions
This file was deleted.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// -----------------------------------------------------------------------------------------
2+
// Prepare the Node environment to support loading .jsx/.ts/.tsx files without needing precompilation,
3+
// since that's such a common scenario. In the future, this might become a config option.
4+
// This is bundled in with the actual prerendering logic below just to simplify the initialization
5+
// logic (we can't have cross-file imports, because these files don't exist on disk until the
6+
// StringAsTempFile utility puts them there temporarily).
7+
8+
// TODO: Consider some general method for checking if you have all the necessary NPM modules installed,
9+
// and if not, giving an error that tells you what command to execute to install the missing ones.
10+
var fs = require('fs');
11+
var ts = require('ntypescript');
12+
var babelCore = require('babel-core');
13+
var resolveBabelRc = require('babel-loader/lib/resolve-rc'); // If this ever breaks, we can easily scan up the directory hierarchy ourselves
14+
var origJsLoader = require.extensions['.js'];
15+
16+
function resolveBabelOptions(relativeToFilename) {
17+
var babelRcText = resolveBabelRc(relativeToFilename);
18+
return babelRcText ? JSON.parse(babelRcText) : {};
19+
}
20+
21+
function loadViaTypeScript(module, filename) {
22+
// First perform a minimal transpilation from TS code to ES2015. This is very fast (doesn't involve type checking)
23+
// and is unlikely to need any special compiler options
24+
var src = fs.readFileSync(filename, 'utf8');
25+
var compilerOptions = { jsx: ts.JsxEmit.Preserve, module: ts.ModuleKind.ES2015, target: ts.ScriptTarget.ES6 };
26+
var es6Code = ts.transpile(src, compilerOptions, 'test.tsx', /* diagnostics */ []);
27+
28+
// Second, process the ES2015 via Babel. We have to do this (instead of going directly from TS to ES5) because
29+
// TypeScript's ES5 output isn't exactly compatible with Node-style CommonJS modules. The main issue is with
30+
// resolving default exports - https://github.com/Microsoft/TypeScript/issues/2719
31+
var es5Code = babelCore.transform(es6Code, resolveBabelOptions(filename)).code;
32+
return module._compile(es5Code, filename);
33+
}
34+
35+
function loadViaBabel(module, filename) {
36+
// Assume that all the app's own code is ES2015+ (optionally with JSX), but that none of the node_modules are.
37+
// The distinction is important because ES2015+ forces strict mode, and it may break ES3/5 if you try to run it in strict
38+
// mode when the developer didn't expect that (e.g., current versions of underscore.js can't be loaded in strict mode).
39+
var useBabel = filename.indexOf('node_modules') < 0;
40+
if (useBabel) {
41+
var transformedFile = babelCore.transformFileSync(filename, resolveBabelOptions(filename));
42+
return module._compile(transformedFile.code, filename);
43+
} else {
44+
return origJsLoader.apply(this, arguments);
45+
}
46+
}
47+
48+
function register() {
49+
require.extensions['.js'] = loadViaBabel;
50+
require.extensions['.jsx'] = loadViaBabel;
51+
require.extensions['.ts'] = loadViaTypeScript;
52+
require.extensions['.tsx'] = loadViaTypeScript;
53+
};
54+
55+
register();
56+
57+
// -----------------------------------------------------------------------------------------
58+
// Rendering
59+
60+
var url = require('url');
61+
var path = require('path');
62+
var domain = require('domain');
63+
var domainTask = require('domain-task');
64+
var baseUrl = require('domain-task/fetch').baseUrl;
65+
66+
function findBootFunc(bootModulePath, bootModuleExport) {
67+
var resolvedPath = path.resolve(process.cwd(), bootModulePath);
68+
var bootFunc = require(resolvedPath);
69+
if (bootModuleExport) {
70+
bootFunc = bootFunc[bootModuleExport];
71+
} else if (typeof bootFunc !== 'function') {
72+
bootFunc = bootFunc.default; // TypeScript sometimes uses this name for default exports
73+
}
74+
if (typeof bootFunc !== 'function') {
75+
if (bootModuleExport) {
76+
throw new Error('The module at ' + bootModulePath + ' has no function export named ' + bootModuleExport + '.');
77+
} else {
78+
throw new Error('The module at ' + bootModulePath + ' does not export a default function, and you have not specified which export to invoke.');
79+
}
80+
}
81+
82+
return bootFunc;
83+
}
84+
85+
function renderToString(callback, bootModulePath, bootModuleExport, absoluteRequestUrl, requestPathAndQuery) {
86+
var bootFunc = findBootFunc(bootModulePath, bootModuleExport);
87+
88+
// Prepare a promise that will represent the completion of all domain tasks in this execution context.
89+
// The boot code will wait for this before performing its final render.
90+
var domainTaskCompletionPromiseResolve;
91+
var domainTaskCompletionPromise = new Promise(function (resolve, reject) {
92+
domainTaskCompletionPromiseResolve = resolve;
93+
});
94+
var params = {
95+
location: url.parse(requestPathAndQuery),
96+
url: requestPathAndQuery,
97+
domainTasks: domainTaskCompletionPromise
98+
};
99+
100+
// Open a new domain that can track all the async tasks involved in the app's execution
101+
domainTask.run(function() {
102+
// Workaround for Node bug where native Promise continuations lose their domain context
103+
// (https://github.com/nodejs/node-v0.x-archive/issues/8648)
104+
bindPromiseContinuationsToDomain(domainTaskCompletionPromise, domain.active);
105+
106+
// Make the base URL available to the 'domain-tasks/fetch' helper within this execution context
107+
baseUrl(absoluteRequestUrl);
108+
109+
// Actually perform the rendering
110+
bootFunc(params).then(function(successResult) {
111+
callback(null, { html: successResult.html, globals: successResult.globals });
112+
}, function(error) {
113+
callback(error, null);
114+
});
115+
}, function allDomainTasksCompleted(error) {
116+
// There are no more ongoing domain tasks (typically data access operations), so we can resolve
117+
// the domain tasks promise which notifies the boot code that it can do its final render.
118+
if (error) {
119+
callback(error, null);
120+
} else {
121+
domainTaskCompletionPromiseResolve();
122+
}
123+
});
124+
}
125+
126+
function bindPromiseContinuationsToDomain(promise, domainInstance) {
127+
var originalThen = promise.then;
128+
promise.then = function then(resolve, reject) {
129+
if (typeof resolve === 'function') { resolve = domainInstance.bind(resolve); }
130+
if (typeof reject === 'function') { reject = domainInstance.bind(reject); }
131+
return originalThen.call(this, resolve, reject);
132+
};
133+
}
134+
135+
module.exports.renderToString = renderToString;

0 commit comments

Comments
 (0)