Skip to content

Commit

Permalink
feat: allow plugins to hook client and server rendering
Browse files Browse the repository at this point in the history
  • Loading branch information
ephys committed Sep 24, 2018
1 parent c3107be commit d6c2eb0
Show file tree
Hide file tree
Showing 8 changed files with 203 additions and 59 deletions.
5 changes: 5 additions & 0 deletions src/framework/client/client-hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// webpack-injected script
// eslint-disable-next-line no-undef
const hooks = $$RJS_VARS$$.HOOKS_CLIENT;

export default hooks;
24 changes: 21 additions & 3 deletions src/framework/client/init-render.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,41 @@ import { applyRouterMiddleware, Router } from 'react-router';
import { CookiesProvider } from 'react-cookie';
import serverStyleCleanup from 'node-style-loader/clientCleanup';
import { useScroll } from 'react-router-scroll';
import { getDefault } from '../../shared/util/ModuleUtil';
import ReworkRootComponent from '../app/ReworkRootComponent';
import { rootRoute, history } from '../common/kernel';
import ClientHooks from './client-hooks';

ReactDOM.render(
let rootComponent = (
<CookiesProvider>
<ReworkRootComponent>
<Router
history={history}
routes={rootRoute}
render={

// Scroll to top when going to a new page, imitating default browser behaviour
applyRouterMiddleware(useScroll())
}
/>
</ReworkRootComponent>
</CookiesProvider>,
</CookiesProvider>
);

const clientHooks = ClientHooks.map(hookModule => {
const HookClass = getDefault(hookModule);

return new HookClass();
});

// allow plugins to add components
for (const clientHook of clientHooks) {
if (clientHook.wrapRootComponent) {
rootComponent = clientHook.wrapRootComponent(rootComponent);
}
}

ReactDOM.render(
rootComponent,
document.getElementById('app'),
);

Expand Down
5 changes: 5 additions & 0 deletions src/framework/server/server-hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// webpack-injected script
// eslint-disable-next-line no-undef
const hooks = $$RJS_VARS$$.HOOKS_SERVER;

export default hooks;
148 changes: 92 additions & 56 deletions src/framework/server/setup-http-server/serve-react-route.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { match, RouterContext } from 'react-router';
import { renderToString } from 'react-dom/server';
import { collectInitial, collectContext } from 'node-style-loader/collect';
import { parse } from 'accept-language-parser';
import { getDefault } from '../../../shared/util/ModuleUtil';
import getWebpackSettings from '../../../shared/webpack-settings';
import { rootRoute } from '../../common/kernel';
import ReworkRootComponent from '../../app/ReworkRootComponent';
import ServerHooks from '../server-hooks';
import { setRequestLocales } from './request-locale';
import renderPage from './render-page';

Expand Down Expand Up @@ -43,71 +45,105 @@ export default async function serveReactRoute(req, res, next): ?{ appHtml: strin
return;
}

const matchedRoute = props.routes[props.routes.length - 1];
if (matchedRoute.status) {
res.status(matchedRoute.status);
}
// TODO hook SSR wrapMainComponent
const serverHooks = ServerHooks.map(hookModule => {
const HookClass = getDefault(hookModule);

setRequestLocales(
parse(req.header('Accept-Language'))
.map(parsedLocale => {
let localeStr = parsedLocale.code;

if (parsedLocale.region) {
localeStr += `-${parsedLocale.region}`;
}

return localeStr;
}),
);

const renderApp = () => renderToString(
<CookiesProvider cookies={req.universalCookies}>
<ReworkRootComponent>
<RouterContext {...props} />
</ReworkRootComponent>
</CookiesProvider>,
);

const compilationStats = await getCompilationStats();

let header = '';
let appHtml;
if (process.env.NODE_ENV === 'development') {
// There is no CSS entry point in dev mode, generate it with collectInitial/collectContext instead.
header += collectInitial();
const renderedApp = collectContext(renderApp);
header += renderedApp[0]; // 0 = collected CSS
appHtml = renderedApp[1]; // 1 = rendered HTML
} else {
header += compilationStats.client.entryPoints.css;
appHtml = renderApp();
}
return new HookClass();
});

const importedServerChunks: Set = unhookWebpackAsyncRequire();
const importableClientChunks = [];
for (const importedServerChunk of importedServerChunks) {
const chunkFiles: ?Array<string> = getClientFilesFromServerChunkId(importedServerChunk, compilationStats);
if (!chunkFiles) {
continue;
try {
const matchedRoute = props.routes[props.routes.length - 1];
if (matchedRoute.status) {
res.status(matchedRoute.status);
}

importableClientChunks.push(...chunkFiles.map(getChunkPrefetchLink));
}
setRequestLocales(
parse(req.header('Accept-Language'))
.map(parsedLocale => {
let localeStr = parsedLocale.code;

if (parsedLocale.region) {
localeStr += `-${parsedLocale.region}`;
}

return localeStr;
}),
);

let component = (
<CookiesProvider cookies={req.universalCookies}>
<ReworkRootComponent>
<RouterContext {...props} />
</ReworkRootComponent>
</CookiesProvider>
);

// allow plugins to add components
for (const serverHook of serverHooks) {
if (serverHook.wrapRootComponent) {
component = serverHook.wrapRootComponent(component);
}
}

const renderApp = () => renderToString(component);

const compilationStats = await getCompilationStats();

let header = '';
let appHtml;
if (process.env.NODE_ENV === 'development') {
// There is no CSS entry point in dev mode, generate it with collectInitial/collectContext instead.
header += collectInitial();
const renderedApp = collectContext(renderApp);
header += renderedApp[0]; // 0 = collected CSS
appHtml = renderedApp[1]; // 1 = rendered HTML
} else {
header += compilationStats.client.entryPoints.css;
appHtml = renderApp();
}

const importedServerChunks: Set = unhookWebpackAsyncRequire();
const importableClientChunks = [];
for (const importedServerChunk of importedServerChunks) {
const chunkFiles: ?Array<string> = getClientFilesFromServerChunkId(importedServerChunk, compilationStats);
if (!chunkFiles) {
continue;
}

importableClientChunks.push(...chunkFiles.map(getChunkPrefetchLink));
}

header += importableClientChunks.join('');

let htmlParts = {

header += importableClientChunks.join('');
// initial react app
body: appHtml,

res.send(renderPage({
// initial style & pre-loaded JS
header,

// initial react app
body: appHtml,
// inject main webpack bundle
footer: `${compilationStats.client.entryPoints.js}`,
};

// initial style & pre-loaded JS
header,
// allow plugins to edit HTML (add script, etc) before actual render.
for (const serverHook of serverHooks) {
if (serverHook.preRender) {
htmlParts = serverHook.preRender(htmlParts);
}
}

// inject main webpack bundle
footer: `${compilationStats.client.entryPoints.js}`,
}));
res.send(renderPage(htmlParts));
} finally {
// allow plugins to cleanup
for (const serverHook of serverHooks) {
if (serverHook.postRequest) {
serverHook.postRequest();
}
}
}
} catch (e) {
next(e);
} finally {
Expand Down
56 changes: 56 additions & 0 deletions src/internals/get-plugins.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// @flow

import frameworkConfig from '../shared/framework-config';

type PluginHooks = {
client: string,
server: string,
};

type FrameworkPlugin = {
getInstallableDependencies: ?() => { [string]: string },
getHooks: ?(() => PluginHooks),
};

/**
* Loads and returns ReworkJS plugin instances (eg. @reworkjs/redux)
*/
export default function getPlugins(): FrameworkPlugin[] {
const pluginConfigs = frameworkConfig.plugins;

if (!pluginConfigs) {
return [];
}

const plugins = [];
for (const pluginConfig of pluginConfigs) {
const pluginUri = typeof pluginConfig === 'string' ? pluginConfig : pluginConfig.plugin;
const config = typeof pluginConfig === 'string' ? null : pluginConfig.config;

// $FlowIgnore
const Plugin = require(pluginUri);
const pluginInstance = new Plugin(config);

plugins.push(pluginInstance);
}

return plugins;
}

export function getHooks(side: string): string[] {

const hooks = [];

for (const plugin of getPlugins()) {
if (!plugin.getHooks) {
continue;
}

const pluginHooks = plugin.getHooks();
if (pluginHooks[side]) {
hooks.push(pluginHooks[side]);
}
}

return hooks;
}
14 changes: 14 additions & 0 deletions src/internals/webpack/WebpackBase.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import CopyWebpackPlugin from 'copy-webpack-plugin';
import frameworkConfig from '../../shared/framework-config';
import projectMetadata from '../../shared/project-metadata';
import frameworkMetadata from '../../shared/framework-metadata';
import { getHooks } from '../get-plugins';
import { resolveFrameworkSource } from '../util/resolve-util';
import argv from '../rjs-argv';
import logger from '../../shared/logger';
Expand Down Expand Up @@ -400,6 +401,9 @@ export default class WebpackBase {
FRAMEWORK_METADATA: JSON.stringify(frameworkMetadata),
PROJECT_METADATA: JSON.stringify(projectMetadata),
PARSED_ARGV: JSON.stringify(programArgv),

HOOKS_CLIENT: buildRequireArrayScript(getHooks('client')),
HOOKS_SERVER: buildRequireArrayScript(getHooks('server')),
},
};

Expand Down Expand Up @@ -528,3 +532,13 @@ function buildIndexPage() {
body: renderToString(<BaseHelmet />),
});
}

function buildRequireArrayScript(uris: string[]): string {
let script = 'r; var r = [];\n';

for (const uri of uris) {
script += `r.push(require('${uri}'))`;
}

return script;
}
8 changes: 8 additions & 0 deletions src/shared/framework-config/framework-config-type.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@

export type FrameworkPluginConfig = {
plugin: string,
config: any,
};

export type FrameworkConfigStruct = {
directories: {
logs: string,
Expand All @@ -12,4 +18,6 @@ export type FrameworkConfigStruct = {
'render-html': ?string,
'pre-init': ?string,
'service-worker': ?string,

plugins: ?Array<FrameworkPluginConfig | string>,
};
2 changes: 2 additions & 0 deletions src/shared/framework-config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ function isDirectory(dir) {
}
}

// TODO some entries should not be "resolved"
// TODO check pluginConfig is an Array
const config: FrameworkConfigStruct = resolveEntries(merge(defaultConfig, getUserConfig()));

if (config.directories.logs === null) {
Expand Down

0 comments on commit d6c2eb0

Please sign in to comment.