@before.js/server
Version:
Enables data fetching with any React SSR app that uses React Router 5
216 lines (202 loc) • 6.43 kB
JavaScript
//
// The customRenderer parameter is a (potentially async) function that can be set to return more than just a rendered string.
// If present, it will be used instead of the default ReactDOMServer renderToString function.
// It has to return an object of shape { html, ... }, in which html will be used as the rendered string
// Other props will be also pass to the Document component
//
// @flow strict
import type { Extractor, PageProps, Renderer, RenderOptions, Request, Route } from 'render';
import { ChunkExtractor, ChunkExtractorManager } from '@loadable/server';
import { DocumentComponent as DefaultDoc } from './Document.component';
import { fetchInitialPropsFromRoute } from './fetchInitialPropsFromRoute';
import { isError, isPromise } from './utils';
import { complement, isEmpty, F } from 'ramda';
import { StaticRouter } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import Before from './Before.component';
import path from 'path';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
/**
* Check if given value if defined.
*
* @func
* @param {any} value to check
* @returns {boolean}
*/
const isNotEmpty = complement(isEmpty);
/**
* Similar to renderToString, except this doesn't create extra DOM attributes such as data-reactid,
* that React uses internally. This is useful if you want to use React as a simple static page generator,
* as stripping away the extra attributes can save lots of bytes.
* Finally, replace the `BEFORE.JS-DATA` with the result of the _renderToStaticMarkup_.
* @param {React$PureComponent} Document a react PureComponent
* @param {object} docProps Document props object
* @param {string} html initial HTML
*/
const parseDocument = (Document, docProps, html) => {
const { extractor } = docProps;
const rootNode = extractor ? (
<ChunkExtractorManager extractor={extractor}>
<Document {...docProps} />
</ChunkExtractorManager>
) : (
<Document {...docProps} />
);
const doc = ReactDOMServer.renderToStaticMarkup(rootNode);
return `<!doctype html>` + doc.replace('BEFORE.JS-DATA', `${html}`);
};
/**
* Render a React element to its initial HTML.
* @param {React$Element} element a react element
* @returns {object} with given element rendered as an string.
*/
const defaultRenderer: Renderer = element => ({
html: ReactDOMServer.renderToString(element)
});
/**
* Creates a Page component that will inject given props.
* @func
* @param {React$ComponentType} Page a react component
* @returns {function} that will inject given props into the Page component.
*/
const defaultCreatePageComponent = (Page: typeof Before) => (props: PageProps) => (
// $FlowFixMe
<Page {...props} />
);
/**
* Renders given routes with given render function.
*
* @param {object} req an string that represents the url location
* @param {array} routes an array of routes
* @param {function} renderer custom renderer function
* @param {object} context StaticRouter context object
*/
const createRenderPage = (
req: Request,
routes: Array<Route>,
renderer: Renderer = defaultRenderer,
context = {}
) => async (data, createPageComponent = defaultCreatePageComponent) => {
const asyncOrSyncRender = renderer(
<StaticRouter location={req.url} context={context}>
{createPageComponent(Before)({ routes, data, req })}
</StaticRouter>
);
// if the rendered content is a promise, we wait for it to finish
const renderedContent = isPromise(asyncOrSyncRender)
? await asyncOrSyncRender
: asyncOrSyncRender;
const helmet = Helmet.renderStatic();
return { ...renderedContent, helmet };
};
/**
* Creates a new instance of ChunkExtractor if given path is defined.
*
* @func
* @param {string} statsPath
* @returns {object | null} instance of ChunkExtractor
*/
const getExtractor = (statsPath: ?string, entrypoints: Array<string>): ?Extractor => {
let extractor = null;
if (statsPath) {
const statsFile = path.resolve(statsPath);
extractor = new ChunkExtractor({ statsFile, entrypoints });
}
return extractor;
};
/**
* Function that will try to retrieve the intial props for the route that is trying
* to render. Catch any error that will happen during the render process or even fetching
* the initial route props and will render an error component instead the desire route.
*
* @param {object} {
* req,
* res,
* routes,
* assets,
* document: Document = DefaultDoc,
* filterServerData,
* generateCriticalCSS,
* customRenderer,
* statsPath,
* title,
* ...rest
* } RenderOptions an object with all options used to render the initial HTML.
* @returns {Promise}
*/
export async function render({
req,
res,
routes,
assets,
document: Document = DefaultDoc,
filterServerData,
generateCriticalCSS = F,
customRenderer,
statsPath,
title,
...rest
}: RenderOptions) {
const { path, originalUrl } = req;
const extractor = getExtractor(statsPath, ['client']);
const renderPage = createRenderPage(req, routes, customRenderer);
let response = {};
try {
response = await fetchInitialPropsFromRoute(routes, path, {
req,
res,
...rest
});
} catch (error) {
console.error('There was an error while loading the initial props', error);
response = {
data: error,
route: {}
};
}
const { route, data } = response;
if (isNotEmpty(route)) {
if (route.path === '**') {
return res.status(404);
}
if (route.redirectTo && route.path) {
return res.redirect(301, originalUrl.replace(route.path, route.redirectTo));
}
}
let parsedDocument = '';
try {
const docProps = await Document.getInitialProps({
req,
res,
assets,
renderPage,
data,
filterServerData,
generateCriticalCSS,
title,
extractor,
...rest,
error: isError(data) && data,
match: route
});
const { html } = docProps;
parsedDocument = parseDocument(Document, docProps, html);
} catch (error) {
console.error('There was an error while trying to render the initial document', error);
parsedDocument = parseDocument(
Document,
{
assets,
error,
extractor,
data: {},
helmet: Helmet.renderStatic(),
criticalCSS: generateCriticalCSS(),
title
},
''
);
}
return parsedDocument;
}