Skip to content

Instantly share code, notes, and snippets.

@cema-sp
Created July 26, 2017 20:33
Show Gist options
  • Save cema-sp/21f0e2e39cbcc61be4b9eb835958e2f4 to your computer and use it in GitHub Desktop.
Save cema-sp/21f0e2e39cbcc61be4b9eb835958e2f4 to your computer and use it in GitHub Desktop.
Part of Server Side Rendering App
/**
* @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