Skip to content

Commit

Permalink
refactor SSR flow to support await data prepare (#1516)
Browse files Browse the repository at this point in the history
  • Loading branch information
jchip authored Jan 31, 2020
1 parent 0506454 commit 3fe407d
Show file tree
Hide file tree
Showing 7 changed files with 201 additions and 55 deletions.
43 changes: 35 additions & 8 deletions packages/subapp-react/lib/framework-lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ const ReactRouterDom = optionalRequire("react-router-dom");
class FrameworkLib {
constructor(ref) {
this.ref = ref;
this._prepared = false;
}

async handleSSR() {
async handlePrepare() {
this._prepared = true;
const { subApp, subAppServer, options } = this.ref;
// If subapp wants to use react router and server didn't specify a StartComponent,
// then create a wrap StartComponent that uses react router's StaticRouter
Expand All @@ -27,6 +29,24 @@ class FrameworkLib {
this.StartComponent = subAppServer.StartComponent || subApp.Component;
}

if (this.StartComponent) {
if (subApp.__redux) {
return await this.prepareReduxData();
} else if (options.serverSideRendering === true) {
return await this.prepareData();
}
}

return false;
}

async handleSSR() {
const { subApp, options } = this.ref;

if (!this._prepared) {
await this.handlePrepare();
}

if (!this.StartComponent) {
return `<!-- serverSideRendering ${subApp.name} has no StartComponent -->`;
} else if (subApp.__redux) {
Expand Down Expand Up @@ -86,25 +106,27 @@ class FrameworkLib {
);
}

async doSSR() {
async prepareData() {
const { subApp, subAppServer, context } = this.ref;
const { request } = context.user;

let initialProps;

// even though we don't know what data model the component is using, but if it
// has a prepare callback, we will just call it to get initial props to pass
// to the component when rendering it
const prepare = subAppServer.prepare || subApp.prepare;
if (prepare) {
initialProps = await prepare({ request, context });
this._initialProps = await prepare({ request, context });
}

return await this.renderTo(this.createTopComponent(initialProps), this.ref.options);
return this._initialProps;
}

async doReduxSSR() {
const { subApp, subAppServer, context, options } = this.ref;
async doSSR() {
return await this.renderTo(this.createTopComponent(this._initialProps), this.ref.options);
}

async prepareReduxData() {
const { subApp, subAppServer, context } = this.ref;
const { request } = context.user;
// subApp.reduxReducers || subApp.reduxCreateStore) {
// if sub app has reduxReducers or reduxCreateStore then assume it's using
Expand Down Expand Up @@ -137,6 +159,11 @@ class FrameworkLib {
this.store,
`redux subapp ${subApp.name} didn't provide store, reduxCreateStore, or reducers`
);
}

async doReduxSSR() {
const { options } = this.ref;

if (options.serverSideRendering === true) {
assert(Provider, "subapp-web: react-redux Provider not available");
// finally render the element with Redux Provider and the store created
Expand Down
20 changes: 20 additions & 0 deletions packages/subapp-react/test/spec/ssr-framework.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,26 @@ describe("SSR React framework", function() {
expect(res).contains("Hello foo bar");
});

it("should allow preparing data before SSR", async () => {
const framework = new lib.FrameworkLib({
subApp: {
prepare: () => ({ test: "foo bar" }),
Component: props => {
return <div>Hello {props.test}</div>;
}
},
subAppServer: {},
options: { serverSideRendering: true },
context: {
user: {}
}
});
await framework.handlePrepare();
expect(framework._initialProps).to.be.ok;
const res = await framework.handleSSR();
expect(res).contains("Hello foo bar");
});

it("should render Component with streaming if enabled", () => {
const framework = new lib.FrameworkLib({
subApp: {
Expand Down
129 changes: 86 additions & 43 deletions packages/subapp-web/lib/load.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,14 @@ const Path = require("path");
const _ = require("lodash");
const retrieveUrl = require("request");
const util = require("./util");
const xaa = require("xaa");
const { loadSubAppByName, loadSubAppServerByName } = require("subapp-util");

// global name to store client subapp runtime, ie: window.xarcV1
// V1: version 1.
const xarc = "window.xarcV1";

module.exports = function setup(setupContext, token) {
const options = token.props;

module.exports = function setup(setupContext, { props: options }) {
// TODO: create JSON schema to validate props

// name="Header"
Expand Down Expand Up @@ -160,25 +159,89 @@ module.exports = function setup(setupContext, token) {
const clientProps = JSON.stringify(_.pick(options, ["useReactRouter"]));

return {
process: context => {
process: (context, { props }) => {
const { request } = context.user;

if (request.app.webpackDev && subAppLoadTime < request.app.webpackDev.compileTime) {
subAppLoadTime = request.app.webpackDev.compileTime;
loadSubApp();
}

let { group = "_" } = props;
group = [].concat(group);
const ssrGroups = group.map(grp =>
util.getOrSet(context, ["user", "xarcSubappSSR", grp], { queue: [] })
);

//
// push {awaitData, ready, renderSSR, props} into queue
//
// awaitData - promise
// ready - defer promise to signal SSR info is ready for processing
// props - token.props
// renderSSR - callback to start rendering SSR for the group
//

const ssrInfo = { props, group, ready: xaa.defer() };
ssrGroups.forEach(grp => grp.queue.push(ssrInfo));

const outputSpot = context.output.reserve();
// console.log("subapp load", name, "useReactRouter", subApp.useReactRouter);

const outputSSRContent = (ssrContent, initialStateStr) => {
// If user specified an element ID for a DOM Node to host the SSR content then
// add the div for the Node and the SSR content to it, and add JS to start the
// sub app on load.
let elementId = "";
if (options.elementId) {
elementId = `elementId:"${options.elementId}",\n `;
outputSpot.add(`<div id="${options.elementId}">`);
outputSpot.add(ssrContent); // must add by itself since this could be a stream
outputSpot.add(`</div>`);
} else {
outputSpot.add("<!-- no elementId for starting subApp on load -->\n");
outputSpot.add(ssrContent);
}

outputSpot.add(`
<script>${xarc}.startSubAppOnLoad({
name:"${name}",
${elementId}serverSideRendering:${Boolean(options.serverSideRendering)},
clientProps:${clientProps},
initialState:${initialStateStr || "{}"}
});</script>
`);
};

const handleError = err => {
if (process.env.NODE_ENV !== "production") {
const stack = util.removeCwd(err.stack);
console.error(`SSR subapp ${name} failed <error>${stack}</error>`); // eslint-disable-line
outputSpot.add(`<!-- SSR subapp ${name} failed
${stack}
-->`);
} else if (request && request.log) {
request.log(["error"], { msg: `SSR subapp ${name} failed`, err });
}
};

const closeOutput = () => {
if (options.timestamp) {
outputSpot.add(`<!-- time: ${Date.now()} -->`);
}

outputSpot.close();
};

const processSubapp = async () => {
let ssrContent = "";
let initialStateStr = "";
const ref = {
context,
subApp,
subAppServer,
options
options,
ssrGroups
};

const { bundles, scripts } = await prepareSubAppSplitBundles(context);
Expand All @@ -191,29 +254,19 @@ module.exports = function setup(setupContext, token) {

if (options.serverSideRendering) {
const lib = util.getFramework(ref);
ssrContent = await lib.handleSSR(ref);
initialStateStr = lib.initialStateStr;
ssrInfo.awaitData = lib.handlePrepare();

ssrInfo.renderSSR = async () => {
try {
outputSSRContent(await lib.handleSSR(ref), lib.initialStateStr);
} catch (err) {
handleError(err);
} finally {
closeOutput();
}
};
} else {
ssrContent = `<!-- serverSideRendering flag is ${options.serverSideRendering} -->`;
}

// If user specified an element ID for a DOM Node to host the SSR content then
// add the div for the Node and the SSR content to it, and add JS to start the
// sub app on load.
if (options.elementId) {
outputSpot.add(`<div id="${options.elementId}">`);
outputSpot.add(ssrContent); // must add by itself since this could be a stream
outputSpot.add(`</div>
<script>${xarc}.startSubAppOnLoad({
name:"${name}",
elementId:"${options.elementId}",
serverSideRendering:${Boolean(options.serverSideRendering)},
clientProps:${clientProps},
initialState:${initialStateStr || "{}"}
});</script>
`);
} else {
outputSpot.add("<!-- no elementId for starting subApp on load -->\n");
outputSSRContent("");
}
};

Expand All @@ -224,23 +277,13 @@ module.exports = function setup(setupContext, token) {

try {
await processSubapp();
ssrInfo.ready.resolve();
} catch (err) {
if (process.env.NODE_ENV !== "production") {
console.error(`SSR subapp ${name} failed <error>${err.stack}</error>`); // eslint-disable-line
outputSpot.add(`<!-- SSR subapp ${name} failed
${err.stack}
-->`);
} else if (request && request.log) {
request.log(["error"], { msg: `SSR subapp ${name} failed`, err });
}
handleError(err);
} finally {
if (options.timestamp) {
outputSpot.add(`<!-- time: ${Date.now()} -->`);
if (!ssrInfo.renderSSR) {
closeOutput();
}

outputSpot.close();
}
};

Expand Down
40 changes: 38 additions & 2 deletions packages/subapp-web/lib/start.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,49 @@
"use strict";

const DEFAULT_CONCURRENCY = 15;
const xaa = require("xaa");
/*
* subapp start for SSR
* Nothing needs to be done to start subapp for SSR
*/
module.exports = function setup() {
return {
process: () => {
return "\n<!-- subapp start -->\n";
process: (context, { props: { concurrency } }) => {
const { xarcSubappSSR } = context.user;
const startMsg = "\n<!-- subapp start -->\n";

if (!xarcSubappSSR) return startMsg;

concurrency = concurrency > 0 ? concurrency : DEFAULT_CONCURRENCY;

xaa.map(
Object.entries(xarcSubappSSR),
async ([, { queue }]) => {
await xaa.map(
queue,
async info => {
// make sure subapp is ready with SSR
if (info.ready) await info.ready.promise;
// and then wait for it to complete data prepare
// awaitData should be available once ready is awaited
await info.awaitData;
},
{ concurrency }
);

// finally kick off rendering for every subapp in the group
await xaa.map(
queue,
async ({ renderSSR }) => {
if (renderSSR) await renderSSR();
},
{ concurrency }
);
},
{ concurrency }
);

return startMsg;
}
};
};
19 changes: 19 additions & 0 deletions packages/subapp-web/lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ let CDN_JS_BUNDLES;
let FrameworkLib;

const utils = {
getOrSet(obj, path, initial) {
const x = _.get(obj, path);
if (x === undefined) {
_.set(obj, path, initial);
return initial;
}
return x;
},

removeCwd(msg) {
if (!msg) return msg;
const cwd = process.cwd();
if (cwd.length > 1) {
const regex = new RegExp(cwd + Path.sep, "g");
return msg.replace(regex, `.${Path.sep}`);
}
return msg;
},

getFramework(ref) {
return new FrameworkLib(ref);
},
Expand Down
3 changes: 2 additions & 1 deletion packages/subapp-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"lodash": "^4.17.15",
"optional-require": "^1.0.0",
"request": "^2.88.0",
"subapp-util": "^1.0.3"
"subapp-util": "^1.0.3",
"xaa": "^1.1.4"
},
"devDependencies": {
"@babel/cli": "^7.2.3",
Expand Down
2 changes: 1 addition & 1 deletion packages/subapp-web/test/spec/start.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ const startToken = require("../../lib/start");

describe("start", function() {
it("should return subapp start HTML", () => {
expect(startToken().process()).contains("subapp start");
expect(startToken().process({ user: {} }, { props: {} })).contains("subapp start");
});
});

0 comments on commit 3fe407d

Please sign in to comment.