Created
July 26, 2017 20:33
-
-
Save cema-sp/21f0e2e39cbcc61be4b9eb835958e2f4 to your computer and use it in GitHub Desktop.
Part of Server Side Rendering App
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @flow | |
*/ | |
import path from 'path'; | |
import chalk from 'chalk'; | |
import fs from 'fs'; | |
import serialize from 'serialize-javascript'; // XSS-safe JSON.stringify() | |
import Express from 'express'; | |
import type { $Application, $Request, $Response } from 'express'; | |
import compressionMiddleware from 'compression'; | |
import helmetMiddleware from 'helmet'; | |
import httpProxy from 'http-proxy-middleware'; | |
import isMobile from 'ismobilejs'; | |
import React from 'react'; | |
import { renderToString } from 'react-dom/server'; | |
import { configureStore } from 'redux/store'; | |
import { Provider } from 'react-redux'; | |
import { createServerRenderContext } from 'react-router'; | |
import Helmet from 'react-helmet'; | |
import App from 'App'; | |
import logger from './logger'; | |
const indexHtmlPath = path.resolve('build', 'index.html'); | |
type Params = { | |
env: Object, | |
handledRoutes: HandledRoute[], | |
scripts: string[], | |
styles: string[], | |
}; | |
type RenderReactProps = { | |
store: Object, | |
isPhone: boolean, | |
location: string, | |
context: Object, | |
}; | |
class Server { | |
env: { [string]: any }; | |
handledRoutes: HandledRoute[]; | |
scripts: string[]; | |
styles: string[]; | |
server: $Application; | |
instance: Server; | |
indexHtml: string; | |
constructor(params: Params) { | |
// Singleton | |
if (this.instance) { | |
return this.instance; | |
} | |
this.env = params.env; | |
this.handledRoutes = params.handledRoutes; | |
this.scripts = params.scripts; | |
this.styles = params.styles; | |
this.server = Express(); | |
} | |
start() { | |
const { HOST, PORT, BASENAME, NODE_ENV } = this.env; | |
this.server.listen(PORT, HOST, (err: Error) => { | |
logger.info(chalk.green(`Starting in [${chalk.red(NODE_ENV)}] mode...`)); | |
if (err) { | |
logger.error(chalk.red('Could not start server!'), err); | |
return; | |
} | |
const link = `http://${HOST}:${PORT}${BASENAME}`; | |
logger.info(chalk.green(`App listening on ${chalk.cyan(link)}!`)); | |
}); | |
} | |
configure() { | |
return this | |
.cacheIndexHtml() | |
.then(() => { | |
this.server.use(compressionMiddleware()); | |
this.server.use(helmetMiddleware()); | |
this.configureViews(); | |
this.configureRouteHandlers(); | |
/** | |
* Serve static assets from build directory & service workers from the root | |
* Useful for development | |
*/ | |
if (this.env.SERVE_STATIC_ASSETS) { | |
this.serveStaticAssets(); | |
} | |
/** | |
* Proxy API requests to backend server | |
* Useful for development | |
*/ | |
if (this.env.PROXY) { | |
this.proxyRequests(); | |
} | |
this.instance = this; | |
}); | |
} | |
cacheIndexHtml() { | |
return (new Promise((resolve, reject) => { | |
fs.readFile(indexHtmlPath, 'utf8', (err, file) => { | |
if (err) { reject(err) } | |
this.indexHtml = file; | |
resolve(); | |
}); | |
})); | |
} | |
configureViews() { | |
const viewsPath = path.resolve('public'); | |
this.server.set('view engine', 'ejs'); | |
this.server.set('views', viewsPath); | |
logger.debug(`Serving views from ${viewsPath}`); | |
} | |
serveStaticAssets() { | |
const basename = this.env.BASENAME; | |
// Serve service workers from the root | |
const serviceWorkers = ['pp-service-worker.js']; | |
serviceWorkers.forEach((worker) => { | |
this.server.get(new RegExp(`/${worker}`), (_: $Request, response: $Response) => | |
response.sendFile(path.resolve('build', worker)) | |
); | |
}); | |
// Do not server server code | |
const serverAssets = new RegExp(`${basename}/server`); | |
this.server.get(serverAssets, (request: $Request, response: $Response) => { | |
logger.info(`Requested server asset: ${request.url}`); | |
response.status(404).end(); | |
}); | |
const buildAssets = [`${basename}/`]; | |
this.server.use(buildAssets, Express.static(path.resolve('build'))); | |
const servedAssets = [].concat(buildAssets, serviceWorkers); | |
logger.info( | |
chalk.yellow(`Serving static assets for resources: ${servedAssets.join(', ')}`) | |
); | |
} | |
proxyRequests() { | |
const proxiblePaths = /^(\/v1).*$/; | |
function canProxy(pathname) { | |
return proxiblePaths.test(pathname); | |
} | |
// See _scripts/start.js_ | |
this.server.use(proxiblePaths, httpProxy(canProxy, { | |
target: this.env.PROXY, | |
secure: false, | |
// logLevel: 'info', | |
logProvider: () => ({ | |
log: logger.info, | |
debug: logger.debug, | |
info: logger.info, | |
warn: logger.warn, | |
error: logger.error, | |
}), | |
changeOrigin: true, | |
})); | |
} | |
configureRouteHandlers() { | |
const handledRoutePaths = this.handledRoutes.map((r) => r.path); | |
logger.debug(`Configure handlers for routes: ${handledRoutePaths.join(', ')}`); | |
this.handledRoutes.forEach((route) => { | |
this.server.get(route.path, this.buildRouteHandler(route).bind(this)); | |
}); | |
} | |
buildRouteHandler(route: HandledRoute) { | |
return function(request: $Request, response: $Response) { | |
this.requestHandler(route, request, response); | |
}; | |
} | |
requestHandler(route: HandledRoute, request: $Request, response: $Response) { | |
const location = request.url; | |
const isPhone = isMobile(request.headers['user-agent']).phone; | |
const orderedParams = | |
Object.keys(request.params) | |
.map((key) => [key, request.params[key]]) | |
.sort((a, b) => (parseInt(a, 10) - parseInt(b, 10))) | |
.map(([key, value]) => value); | |
const { scripts, styles } = this; | |
const requestMessage = | |
`Requested URL (${isPhone ? 'mobile' : 'desktop'}): ${location} with params: `; | |
logger.debug(chalk.yellow(requestMessage), serialize(orderedParams)); | |
const context = createServerRenderContext(); | |
const store = configureStore(); | |
(store: Object) | |
.runSaga(route.saga, ...orderedParams) | |
.done | |
.then((_result) => { | |
let { head, markup } = this.renderReact({ store, isPhone, location, context }); | |
const result = context.getResult(); | |
if (result.redirect) { | |
response.redirect(301, result.redirect.pathname); | |
} else if (result.missed) { | |
response.status(404); | |
// Render <Miss /> component | |
const renderedMiss = this.renderReact({ store, isPhone, location, context }); | |
head = renderedMiss.head; | |
markup = renderedMiss.markup; | |
} else { | |
response.status(200); | |
} | |
const storeState = store.getState(); | |
response.render('index', { | |
cache: true, | |
storeState: serialize(storeState, { isJSON: true }), | |
head, | |
markup, | |
scripts, | |
styles, | |
}); | |
}) | |
.catch((err) => { | |
logger.error("Server-Side Rendering failed, serving 'index.html'", err); | |
// response.sendFile(path.resolve('build', 'index.html')); | |
response.send(this.indexHtml); | |
}); | |
// TODO: does it required? | |
// Fire 'componentWillMount' and Signal sagas to finish | |
this.renderReact({ store, isPhone, location, context }); | |
(store: Object).close(); | |
} | |
renderReact(props: RenderReactProps) { | |
const { store, isPhone, location, context } = props; | |
const markup = renderToString( | |
<Provider store={store}> | |
<App | |
routerProps={{ location, context }} | |
isPhone={isPhone || false} | |
/> | |
</Provider> | |
); | |
const head = Helmet.rewind(); | |
return { head, markup }; | |
} | |
} | |
export default Server; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment