From 3fe407d2f985850ec046aace1c10a3927127b9ea Mon Sep 17 00:00:00 2001 From: Joel Chen Date: Thu, 30 Jan 2020 17:37:20 -0800 Subject: [PATCH] refactor SSR flow to support await data prepare (#1516) --- packages/subapp-react/lib/framework-lib.js | 43 ++++-- .../test/spec/ssr-framework.spec.js | 20 +++ packages/subapp-web/lib/load.js | 129 ++++++++++++------ packages/subapp-web/lib/start.js | 40 +++++- packages/subapp-web/lib/util.js | 19 +++ packages/subapp-web/package.json | 3 +- packages/subapp-web/test/spec/start.spec.js | 2 +- 7 files changed, 201 insertions(+), 55 deletions(-) diff --git a/packages/subapp-react/lib/framework-lib.js b/packages/subapp-react/lib/framework-lib.js index a94beb2e4..b3b8c3bc8 100644 --- a/packages/subapp-react/lib/framework-lib.js +++ b/packages/subapp-react/lib/framework-lib.js @@ -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 @@ -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 ``; } else if (subApp.__redux) { @@ -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 @@ -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 diff --git a/packages/subapp-react/test/spec/ssr-framework.spec.js b/packages/subapp-react/test/spec/ssr-framework.spec.js index fa4a37176..c46ca075c 100644 --- a/packages/subapp-react/test/spec/ssr-framework.spec.js +++ b/packages/subapp-react/test/spec/ssr-framework.spec.js @@ -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
Hello {props.test}
; + } + }, + 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: { diff --git a/packages/subapp-web/lib/load.js b/packages/subapp-web/lib/load.js index 195749da1..caa5011b1 100644 --- a/packages/subapp-web/lib/load.js +++ b/packages/subapp-web/lib/load.js @@ -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" @@ -160,7 +159,7 @@ 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) { @@ -168,17 +167,81 @@ module.exports = function setup(setupContext, token) { 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(`
`); + outputSpot.add(ssrContent); // must add by itself since this could be a stream + outputSpot.add(`
`); + } else { + outputSpot.add("\n"); + outputSpot.add(ssrContent); + } + + outputSpot.add(` + +`); + }; + + const handleError = err => { + if (process.env.NODE_ENV !== "production") { + const stack = util.removeCwd(err.stack); + console.error(`SSR subapp ${name} failed ${stack}`); // eslint-disable-line + outputSpot.add(``); + } else if (request && request.log) { + request.log(["error"], { msg: `SSR subapp ${name} failed`, err }); + } + }; + + const closeOutput = () => { + if (options.timestamp) { + outputSpot.add(``); + } + + outputSpot.close(); + }; + const processSubapp = async () => { - let ssrContent = ""; - let initialStateStr = ""; const ref = { context, subApp, subAppServer, - options + options, + ssrGroups }; const { bundles, scripts } = await prepareSubAppSplitBundles(context); @@ -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 = ``; - } - - // 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(`
`); - outputSpot.add(ssrContent); // must add by itself since this could be a stream - outputSpot.add(`
- -`); - } else { - outputSpot.add("\n"); + outputSSRContent(""); } }; @@ -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 ${err.stack}`); // eslint-disable-line - outputSpot.add(``); - } else if (request && request.log) { - request.log(["error"], { msg: `SSR subapp ${name} failed`, err }); - } + handleError(err); } finally { - if (options.timestamp) { - outputSpot.add(``); + if (!ssrInfo.renderSSR) { + closeOutput(); } - - outputSpot.close(); } }; diff --git a/packages/subapp-web/lib/start.js b/packages/subapp-web/lib/start.js index f25e22f70..d503d5ea9 100644 --- a/packages/subapp-web/lib/start.js +++ b/packages/subapp-web/lib/start.js @@ -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\n"; + process: (context, { props: { concurrency } }) => { + const { xarcSubappSSR } = context.user; + const startMsg = "\n\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; } }; }; diff --git a/packages/subapp-web/lib/util.js b/packages/subapp-web/lib/util.js index 5db1d2a49..3232b3a0a 100644 --- a/packages/subapp-web/lib/util.js +++ b/packages/subapp-web/lib/util.js @@ -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); }, diff --git a/packages/subapp-web/package.json b/packages/subapp-web/package.json index 9788495e6..11436ee22 100644 --- a/packages/subapp-web/package.json +++ b/packages/subapp-web/package.json @@ -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", diff --git a/packages/subapp-web/test/spec/start.spec.js b/packages/subapp-web/test/spec/start.spec.js index 28562bf1d..8bc77b729 100644 --- a/packages/subapp-web/test/spec/start.spec.js +++ b/packages/subapp-web/test/spec/start.spec.js @@ -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"); }); });