From 342f3a07bc659523d5655c70e1dc1ff88eff47f6 Mon Sep 17 00:00:00 2001 From: Joel Chen Date: Tue, 31 Dec 2019 10:07:51 -0800 Subject: [PATCH] multi framework support, step1: extract framework specific code (#1480) * multi framework support, step1: extract framework specific code * tests for subapp-web init token --- packages/subapp-web/lib/framework-lib.js | 173 ++++++++++++++++++ packages/subapp-web/lib/load.js | 153 ++-------------- packages/subapp-web/lib/start.js | 4 + packages/subapp-web/lib/util.js | 8 +- packages/subapp-web/test/data/cdn-assets.json | 3 + packages/subapp-web/test/spec/init.spec.js | 31 ++++ packages/subapp-web/test/spec/load.spec.js | 0 packages/subapp-web/test/spec/start.spec.js | 9 + packages/subapp-web/test/spec/util.spec.js | 48 ++++- 9 files changed, 293 insertions(+), 136 deletions(-) create mode 100644 packages/subapp-web/lib/framework-lib.js create mode 100644 packages/subapp-web/test/data/cdn-assets.json create mode 100644 packages/subapp-web/test/spec/init.spec.js create mode 100644 packages/subapp-web/test/spec/load.spec.js create mode 100644 packages/subapp-web/test/spec/start.spec.js diff --git a/packages/subapp-web/lib/framework-lib.js b/packages/subapp-web/lib/framework-lib.js new file mode 100644 index 000000000..f4c35347a --- /dev/null +++ b/packages/subapp-web/lib/framework-lib.js @@ -0,0 +1,173 @@ +"use strict"; + +/* eslint-disable max-statements */ + +/* + In order to support different UI framework like React/Preact, + first extract the framework specific code from subapp lib into + their own file. + Next allow a separate module to DI this into the subapp lib + in order to allow apps to use the framework they choose + */ + +const assert = require("assert"); +const optionalRequire = require("optional-require")(require); +const React = require("react"); +const ReactDOMServer = require("react-dom/server"); +const AsyncReactDOMServer = optionalRequire("react-async-ssr"); +const { default: AppContext } = require("../browser/app-context"); +const ReactRedux = optionalRequire("react-redux", { default: {} }); +const { Provider } = ReactRedux; +const ReactRouterDom = optionalRequire("react-router-dom"); + +class FrameworkLib { + constructor(ref) { + this.ref = ref; + } + + async handleSSR() { + const { subApp, subAppServer, props } = 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 + if (subApp.useReactRouter && !subAppServer.StartComponent) { + this.StartComponent = this.wrapReactRouter(subApp); + } else { + this.StartComponent = subAppServer.StartComponent || subApp.Component; + } + + if (!this.StartComponent) { + return ``; + } else if (subApp.__redux) { + return await this.doReduxSSR(); + } else if (props.serverSideRendering === true) { + return await this.doSSR(); + } + + return ""; + } + + renderTo(element, options = {}) { + if (options.streaming) { + assert(!options.suspenseSsr, "streaming and suspense SSR together are not supported"); + if (options.hydrateServerData) { + return ReactDOMServer.renderToNodeStream(element); + } else { + return ReactDOMServer.renderToStaticNodeStream(element); + } + } + if (options.suspenseSsr) { + assert(AsyncReactDOMServer, "You must install react-async-ssr for suspense SSR support"); + if (options.hydrateServerData) { + return AsyncReactDOMServer.renderToStringAsync(element); + } else { + return AsyncReactDOMServer.renderToStaticMarkupAsync(element); + } + } + if (options.hydrateServerData) { + return ReactDOMServer.renderToString(element); + } else { + return ReactDOMServer.renderToStaticMarkup(element); + } + } + + createTopComponent(initialProps) { + const { request } = this.ref.context.user; + const { subApp } = this.ref; + + let TopComponent; + if (subApp.useReactRouter) { + const rrContext = {}; + const rrProps = Object.assign( + { location: request.url.pathname, context: rrContext }, + initialProps + ); + // console.log("rendering", name, "for react router", rrProps); + TopComponent = React.createElement(this.StartComponent, rrProps); + } else { + // console.log("rendering without react router", name); + TopComponent = React.createElement(this.StartComponent, initialProps); + } + return React.createElement( + AppContext.Provider, + { value: { isSsr: true, subApp, ssr: { request } } }, + TopComponent + ); + } + + async doSSR() { + 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 }); + } + + return await this.renderTo(this.createTopComponent(initialProps), this.ref.props); + } + + async doReduxSSR() { + const { subApp, subAppServer, context, props } = this.ref; + const { request } = context.user; + // subApp.reduxReducers || subApp.reduxCreateStore) { + // if sub app has reduxReducers or reduxCreateStore then assume it's using + // redux data model. prepare initial state and store to render it. + let reduxData; + + // see if app has a prepare callback, on the server side first, and then the + // app itself, and call it. assume the object it returns would contain the + // initial redux state data. + const prepare = subAppServer.prepare || subApp.prepare; + if (prepare) { + reduxData = await prepare({ request, context }); + } + + if (!reduxData) { + reduxData = { initialState: {} }; + } + + this.initialState = reduxData.initialState || reduxData; + // if subapp didn't request to skip sending initial state, then stringify it + // and attach it to the index html. + if (subAppServer.attachInitialState !== false) { + this.initialStateStr = JSON.stringify(this.initialState); + } + // next we take the initial state and create redux store from it + this.store = + reduxData.store || + (subApp.reduxCreateStore && (await subApp.reduxCreateStore(this.initialState))); + assert( + this.store, + `redux subapp ${subApp.name} didn't provide store, reduxCreateStore, or reducers` + ); + if (props.serverSideRendering === true) { + assert(Provider, "subapp-web: react-redux Provider not available"); + // finally render the element with Redux Provider and the store created + return await this.renderTo( + React.createElement(Provider, { store: this.store }, this.createTopComponent()) + ); + } + return ""; + } + + wrapReactRouter() { + assert( + ReactRouterDom && ReactRouterDom.StaticRouter, + `subapp ${this.ref.subApp.name} specified useReactRouter without a StartComponent, \ +and can't generate it because module react-dom-router with StaticRouter is not found` + ); + return props2 => + React.createElement( + ReactRouterDom.StaticRouter, + props2, + React.createElement(this.ref.subApp.Component) + ); + } +} + +module.exports = FrameworkLib; diff --git a/packages/subapp-web/lib/load.js b/packages/subapp-web/lib/load.js index 476cc6c5d..4044c7082 100644 --- a/packages/subapp-web/lib/load.js +++ b/packages/subapp-web/lib/load.js @@ -2,23 +2,24 @@ /* eslint-disable max-statements, no-console, complexity */ -const assert = require("assert"); +/* + * - Figure out all the dependencies and bundles a subapp needs and make sure + * to generate all links to load them for index.html. + * - If serverSideRendering is enabled, then load and render the subapp for SSR. + * - Prepare initial state (if redux enabled) or props for the subapp + * - run renderTo* to generate HTML output + * - include output in index.html + * - generate code to bootstrap subapp on client + */ + const Fs = require("fs"); const Path = require("path"); const _ = require("lodash"); -const optionalRequire = require("optional-require")(require); -const React = require("react"); -const ReactDOMServer = require("react-dom/server"); -const AsyncReactDOMServer = optionalRequire("react-async-ssr"); const retrieveUrl = require("request"); const util = require("./util"); const { loadSubAppByName, loadSubAppServerByName } = require("subapp-util"); -const { default: AppContext } = require("../browser/app-context"); - -const ReactRedux = optionalRequire("react-redux", { default: {} }); -const { Provider } = ReactRedux; -const ReactRouterDom = optionalRequire("react-router-dom"); +const FrameworkLib = require("./framework-lib"); module.exports = function setup(setupContext, token) { const props = token.props; @@ -87,38 +88,8 @@ module.exports = function setup(setupContext, token) { } }; - // - // do server side rendering for the subapp instance - // - const renderElement = element => { - if (props.streaming) { - assert(!props.suspenseSsr, "streaming and suspense SSR together are not supported"); - if (props.hydrateServerData) { - return ReactDOMServer.renderToNodeStream(element); - } else { - return ReactDOMServer.renderToStaticNodeStream(element); - } - } - - if (props.suspenseSsr) { - assert(AsyncReactDOMServer, "You must install react-async-ssr for suspense SSR support"); - if (props.hydrateServerData) { - return AsyncReactDOMServer.renderToStringAsync(element); - } else { - return AsyncReactDOMServer.renderToStaticMarkupAsync(element); - } - } - - if (props.hydrateServerData) { - return ReactDOMServer.renderToString(element); - } else { - return ReactDOMServer.renderToStaticMarkup(element); - } - }; - let subApp; let subAppServer; - let StartComponent; let subAppLoadTime = 0; // @@ -163,7 +134,6 @@ module.exports = function setup(setupContext, token) { const loadSubApp = () => { subApp = loadSubAppByName(name); subAppServer = loadSubAppServerByName(name); - StartComponent = subAppServer.StartComponent || subApp.Component; }; loadSubApp(); @@ -182,103 +152,20 @@ module.exports = function setup(setupContext, token) { const outputSpot = context.output.reserve(); // console.log("subapp load", name, "useReactRouter", subApp.useReactRouter); - let rrContext; - - const createElement = (Component, initialProps) => { - let TopComponent; - if (subApp.useReactRouter) { - rrContext = {}; - const rrProps = Object.assign( - { location: request.url.pathname, context: rrContext }, - initialProps - ); - // console.log("rendering", name, "for react router", rrProps); - TopComponent = React.createElement(Component, rrProps); - } else { - // console.log("rendering without react router", name); - TopComponent = React.createElement(Component, initialProps); - } - return React.createElement( - AppContext.Provider, - { value: { isSsr: true, subApp, ssr: { request } } }, - TopComponent - ); - }; const processSubapp = async () => { let ssrContent = ""; let initialStateStr = ""; + const ref = { + context, + subApp, + subAppServer, + props + }; if (props.serverSideRendering) { - // 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 - if (subApp.useReactRouter && !subAppServer.StartComponent) { - assert( - ReactRouterDom && ReactRouterDom.StaticRouter, - `subapp ${subApp.name} specified useReactRouter without a StartComponent, \ -and can't generate it because module react-dom-router with StaticRouter is not found` - ); - StartComponent = props2 => - React.createElement( - ReactRouterDom.StaticRouter, - props2, - React.createElement(subApp.Component) - ); - } - - if (!StartComponent) { - ssrContent = ``; - } else if (subApp.__redux) { - // subApp.reduxReducers || subApp.reduxCreateStore) { - // if sub app has reduxReducers or reduxCreateStore then assume it's using - // redux data model. prepare initial state and store to render it. - let reduxData; - - // see if app has a prepare callback, on the server side first, and then the - // app itself, and call it. assume the object it returns would contain the - // initial redux state data. - const prepare = subAppServer.prepare || subApp.prepare; - if (prepare) { - reduxData = await prepare({ request, context }); - } - - if (!reduxData) { - reduxData = { initialState: {} }; - } - - const initialState = reduxData.initialState || reduxData; - // if subapp didn't request to skip sending initial state, then stringify it - // and attach it to the index html. - if (subAppServer.attachInitialState !== false) { - initialStateStr = JSON.stringify(initialState); - } - // next we take the initial state and create redux store from it - const store = - reduxData.store || - (subApp.reduxCreateStore && (await subApp.reduxCreateStore(initialState))); - assert( - store, - `redux subapp ${subApp.name} didn't provide store, reduxCreateStore, or reducers` - ); - if (props.serverSideRendering === true) { - assert(Provider, "subapp-web: react-redux Provider not available"); - // finally render the element with Redux Provider and the store created - ssrContent = await renderElement( - React.createElement(Provider, { store }, createElement(StartComponent)) - ); - } - } else if (props.serverSideRendering === true) { - 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 }); - } - - ssrContent = await renderElement(createElement(StartComponent, initialProps)); - } + const lib = new FrameworkLib(ref); + ssrContent = await lib.handleSSR(ref); + initialStateStr = lib.initialStateStr; } else { ssrContent = ``; } diff --git a/packages/subapp-web/lib/start.js b/packages/subapp-web/lib/start.js index 3b37d4c49..f25e22f70 100644 --- a/packages/subapp-web/lib/start.js +++ b/packages/subapp-web/lib/start.js @@ -1,5 +1,9 @@ "use strict"; +/* + * subapp start for SSR + * Nothing needs to be done to start subapp for SSR + */ module.exports = function setup() { return { process: () => { diff --git a/packages/subapp-web/lib/util.js b/packages/subapp-web/lib/util.js index 7b1a865da..d66eed49a 100644 --- a/packages/subapp-web/lib/util.js +++ b/packages/subapp-web/lib/util.js @@ -11,6 +11,10 @@ let CDN_ASSETS; let CDN_JS_BUNDLES; const utils = { + resetCdn() { + CDN_ASSETS = undefined; + CDN_JS_BUNDLES = undefined; + }, // // Each subapp is an entry, which has an array of all bundle IDs // using IDs to lookup bundle name from assets.chunksById @@ -112,7 +116,7 @@ const utils = { return cdnBundles; }, - getCdnJsBundles(assets, routeOptions) { + getCdnJsBundles(assets, routeOptions, cdnAssetsFile) { if (CDN_JS_BUNDLES) { return CDN_JS_BUNDLES; } @@ -125,7 +129,7 @@ const utils = { const bundleBase = utils.getBundleBase(routeOptions); - return (CDN_JS_BUNDLES = utils.mapCdnAssets(chunksById.js, bundleBase)); + return (CDN_JS_BUNDLES = utils.mapCdnAssets(chunksById.js, bundleBase, cdnAssetsFile)); }, getChunksById(stats) { diff --git a/packages/subapp-web/test/data/cdn-assets.json b/packages/subapp-web/test/data/cdn-assets.json new file mode 100644 index 000000000..25ddac269 --- /dev/null +++ b/packages/subapp-web/test/data/cdn-assets.json @@ -0,0 +1,3 @@ +{ + "dist/js/mainbody.bundle.js": "http://cdnasset.com/hash-123.js" +} diff --git a/packages/subapp-web/test/spec/init.spec.js b/packages/subapp-web/test/spec/init.spec.js new file mode 100644 index 000000000..669bb4b1f --- /dev/null +++ b/packages/subapp-web/test/spec/init.spec.js @@ -0,0 +1,31 @@ +"use strict"; + +const { init } = require("../../lib"); +const Path = require("path"); + +// test the init token for subapps + +describe("init", function() { + afterEach(() => { + delete process.env.NODE_ENV; + delete process.env.APP_SRC_DIR; + }); + + it("should return assets as JSON script and little loader", () => { + // point subapp-util to look for subapps under a test dir + process.env.APP_SRC_DIR = "test/subapps"; + + const initToken = init({ + routeOptions: { + __internals: {}, + stats: Path.join(__dirname, "../data/prod-stats.json") + } + }); + + const context = { user: {} }; + const initJs = initToken.process(context); + expect(context.user.assets).to.be.ok; + expect(initJs).contains(`