From 5de0e31fa46fcab3263d0ebf8345e412f30408c5 Mon Sep 17 00:00:00 2001 From: divyakarippath Date: Mon, 10 Aug 2020 09:26:54 -0700 Subject: [PATCH] Refactor SSR to improve initial subapp loading performance (#1717) * Refactoring to improve performance * fixing lint error * fix ssr for prod --- packages/subapp-web/lib/init.js | 110 --------- packages/subapp-web/lib/load.js | 426 -------------------------------- 2 files changed, 536 deletions(-) delete mode 100644 packages/subapp-web/lib/init.js delete mode 100644 packages/subapp-web/lib/load.js diff --git a/packages/subapp-web/lib/init.js b/packages/subapp-web/lib/init.js deleted file mode 100644 index deec5c8f28..0000000000 --- a/packages/subapp-web/lib/init.js +++ /dev/null @@ -1,110 +0,0 @@ -"use strict"; - -/* eslint-disable max-statements */ - -const Fs = require("fs"); -const Path = require("path"); -const util = require("./util"); -const subappUtil = require("subapp-util"); -const _ = require("lodash"); -const assert = require("assert"); - -module.exports = function setup(setupContext) { - const cdnEnabled = _.get(setupContext, "routeOptions.cdn.enable"); - const distDir = process.env.NODE_ENV === "production" ? "../dist/min" : "../dist/dev"; - const clientJs = Fs.readFileSync(Path.join(__dirname, distDir, "subapp-web.js")).toString(); - const cdnJs = cdnEnabled - ? Fs.readFileSync(Path.join(__dirname, distDir, "cdn-map.js")).toString() - : ""; - const loadJs = Fs.readFileSync(require.resolve("loadjs/dist/loadjs.min.js"), "utf8"); - // - // TODO: in webpack dev mode, we need to reload stats after there's a change - // - - const metricReport = _.get(setupContext, "routeOptions.reporting", {}); - - const { assets } = util.loadAssetsFromStats(setupContext.routeOptions.stats); - assert(assets, `subapp-web unable to load assets from ${setupContext.routeOptions.stats}`); - setupContext.routeOptions.__internals.assets = assets; - - const cdnJsBundles = util.getCdnJsBundles(assets, setupContext.routeOptions); - - const bundleAssets = { - jsChunksById: cdnJsBundles, - // md === mapping data for other assets - md: util.getCdnOtherMappings(setupContext.routeOptions), - entryPoints: assets.entryPoints, - basePath: "" - }; - - let inlineRuntimeJS = ""; - let runtimeEntryPoints = []; - if (process.env.NODE_ENV === "production") { - runtimeEntryPoints = Object.keys(assets.chunksById.js).filter(ep => - assets.chunksById.js[ep].startsWith("runtime.bundle") - ); - inlineRuntimeJS = - "/*rt*/" + - runtimeEntryPoints - .map(ep => Path.resolve("dist", "js", Path.basename(cdnJsBundles[ep]))) - .filter(fullPath => Fs.existsSync(fullPath)) - .map(fullPath => Fs.readFileSync(fullPath)) - .join(" ") - .replace(/\/\/#\ssourceMappingURL=.*$/, "") + - "/*rt*/"; - - inlineRuntimeJS += `\nwindow.xarcV1.markBundlesLoaded(${JSON.stringify(runtimeEntryPoints)});`; - } - - const webSubAppJs = ` -`; - - let subAppServers; - - const getSubAppServers = () => { - if (subAppServers) { - return subAppServers; - } - - // TODO: where and how is subApps set in __internals? - const { subApps } = setupContext.routeOptions.__internals; - - // check if any subapp has server side code with initialize method and load them - return (subAppServers = - subApps && - subApps - .map(({ subapp }) => subappUtil.loadSubAppServerByName(subapp.name, false)) - .filter(x => x && x.initialize)); - }; - - return { - process: context => { - context.user.assets = assets; - context.user.includedBundles = {}; - runtimeEntryPoints.forEach(ep => { - context.user.includedBundles[ep] = true; - }); - - if (metricReport.enable && metricReport.reporter) { - context.user.xarcSSREmitter = util.getEventEmiiter(metricReport.reporter); - } - - getSubAppServers(); - - // invoke the initialize method of subapp's server code - if (subAppServers && subAppServers.length > 0) { - for (const server of getSubAppServers()) { - server.initialize(context); - } - } - - return webSubAppJs; - } - }; -}; diff --git a/packages/subapp-web/lib/load.js b/packages/subapp-web/lib/load.js deleted file mode 100644 index 45bb9083ad..0000000000 --- a/packages/subapp-web/lib/load.js +++ /dev/null @@ -1,426 +0,0 @@ -"use strict"; - -/* eslint-disable max-statements, no-console, complexity, no-magic-numbers */ - -/* - * - 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 assert = require("assert"); -const Fs = require("fs"); -const Path = require("path"); -const _ = require("lodash"); -const retrieveUrl = require("request"); -const util = require("./util"); -const xaa = require("xaa"); -const jsesc = require("jsesc"); -const { loadSubAppByName, loadSubAppServerByName, formUrl } = require("subapp-util"); - -// global name to store client subapp runtime, ie: window.xarcV1 -// V1: version 1. -const xarc = "window.xarcV1"; - -// Size threshold of initial state string to embed it as a application/json script tag -// It's more efficient to JSON.parse large JSON data instead of embedding them as JS. -// https://quipblog.com/efficiently-loading-inlined-json-data-911960b0ac0a -// > The data sizes are as follows: large is 1.7MB of JSON, medium is 130K, -// > small is 10K and tiny is 781 bytes. -const INITIAL_STATE_SIZE_FOR_JSON = 1024; -let INITIAL_STATE_TAG_ID = 0; - -const makeDevDebugMessage = (msg, reportLink = true) => { - const reportMsg = reportLink - ? `\nError: Please capture this info and submit a bug report at https://github.com/electrode-io/electrode` - : ""; - return `Error: at ${util.removeCwd(__filename)} -${msg}${reportMsg}`; -}; - -const makeDevDebugHtml = msg => { - return `

DEV ERROR

-

${msg}

-`; -}; - -module.exports = function setup(setupContext, { props: setupProps }) { - // TODO: create JSON schema to validate props - - // name="Header" - // async=true - // defer=true - // useStream=true - // serverSideRendering=true - // hydrateServerData=false - // clientSideRendering=false - // inlineScript=true - - // TODO: how to export and load subapp - - // TODO: Need a way to figure out all the subapps need for a page and send out script - // tags ASAP in
so browser can start fetching them before entire page is loaded. - - const name = setupProps.name; - const routeData = setupContext.routeOptions.__internals; - const bundleAsset = util.getSubAppBundle(name, routeData.assets); - const bundleBase = util.getBundleBase(setupContext.routeOptions); - const comment = process.env.NODE_ENV === "production" ? "\n" : `\n\n`; - - // - // in webpack dev mode, we have to retrieve the subapp's JS bundle from webpack dev server - // to inline in the index page. - // - const retrieveDevServerBundle = async () => { - return new Promise(resolve => { - const routeOptions = setupContext.routeOptions; - const path = Path.posix.join(bundleBase, bundleAsset.name); - const bundleUrl = formUrl({ ...routeOptions.httpDevServer, path }); - retrieveUrl(bundleUrl, (err, resp, body) => { - if (err || resp.statusCode !== 200) { - const msg = makeDevDebugMessage( - `Error: fail to retrieve subapp bundle from '${bundleUrl}' for inlining in index HTML -Response: ${err || body}` - ); - console.error(msg); // eslint-disable-line - resolve(makeDevDebugHtml(msg)); - } else { - resolve(``); - } - }); - }); - }; - - // - // When loading a subapp and its instance in the index, user can choose - // to inline the JS for the subapp's bundle. - // - In production mode, we read its bundle from dist/js - // - In webpack dev mode, we retrieve the bundle from webpack dev server every time - // - let inlineSubAppJs; - - const prepareSubAppJsBundle = () => { - const { webpackDev } = setupContext.routeOptions; - - if (setupProps.inlineScript === "always" || (setupProps.inlineScript === true && !webpackDev)) { - if (!webpackDev) { - // if we have to inline the subapp's JS bundle, we load it for production mode - const src = Fs.readFileSync(Path.resolve("dist/js", bundleAsset.name)).toString(); - const ext = Path.extname(bundleAsset.name); - if (ext === ".js") { - inlineSubAppJs = ``; - } else if (ext === ".css") { - inlineSubAppJs = ``; - } else { - const msg = makeDevDebugMessage(`Error: UNKNOWN bundle extension ${name}`); - console.error(msg); // eslint-disable-line - inlineSubAppJs = makeDevDebugHtml(msg); - } - } else { - inlineSubAppJs = true; - } - } else { - // if should inline script for webpack dev mode - // make sure we retrieve from webpack dev server and inline the script later - inlineSubAppJs = webpackDev && Boolean(setupProps.inlineScript); - } - }; - - let subApp; - let subAppServer; - let subAppLoadTime = 0; - - // - // ensure that other bundles a subapp depends on are loaded - // - const prepareSubAppSplitBundles = async context => { - const { assets, includedBundles } = context.user; - const entryName = name.toLowerCase(); - // - const entryPoints = assets.entryPoints[entryName]; - const cdnJsBundles = util.getCdnJsBundles(assets, setupContext.routeOptions); - - const bundles = entryPoints.filter(ep => !includedBundles[ep]); - const headSplits = []; - const splits = bundles - .map(ep => { - if (!inlineSubAppJs && !includedBundles[entryName]) { - includedBundles[ep] = true; - return ( - cdnJsBundles[ep] && - [] - .concat(cdnJsBundles[ep]) - .reduce((a, jsBundle) => { - const ext = Path.extname(jsBundle); - if (ext === ".js") { - if (context.user.headEntries) { - headSplits.push(``); - } - a.push(``); - } else if (ext === ".css") { - if (context.user.headEntries) { - headSplits.push(``); - } else { - a.push(``); - } - } else { - a.push(``); - } - return a; - }, []) - .join("\n") - ); - } - return false; - }) - .filter(x => x); - - if (inlineSubAppJs && !includedBundles[entryName]) { - includedBundles[entryName] = true; - if (inlineSubAppJs === true) { - splits.push(await retrieveDevServerBundle()); - } else { - splits.push(inlineSubAppJs); - } - } - - return { bundles, scripts: splits.join("\n"), preLoads: headSplits.join("\n") }; - }; - - const loadSubApp = () => { - subApp = loadSubAppByName(name); - subAppServer = loadSubAppServerByName(name, true); - }; - - prepareSubAppJsBundle(); - - const verifyUseStream = props => { - if (props.useStream) { - const routeStream = setupContext.routeOptions.useStream; - assert( - routeStream !== false, - `subapp '${props.name}' can't set useStream when route options 'useStream' is false.` - ); - } - }; - - verifyUseStream(setupProps); - - const clientProps = JSON.stringify(_.pick(setupProps, ["useReactRouter"])); - - return { - process: (context, { props }) => { - verifyUseStream(props); - - const { request } = context.user; - - context.user.numOfSubapps = context.user.numOfSubapps || 0; - - 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 (!props.inline && props.elementId) { - elementId = `elementId:"${props.elementId}",\n `; - outputSpot.add(`
`); - outputSpot.add(ssrContent); // must add by itself since this could be a stream - outputSpot.add(`
`); - } else { - outputSpot.add(""); - if (ssrContent) { - outputSpot.add("\n"); - outputSpot.add(ssrContent); - outputSpot.add("\n"); - } - } - - let dynInitialState = ""; - let initialStateScript; - if (!initialStateStr) { - initialStateScript = "{}"; - } else if (initialStateStr.length < INITIAL_STATE_SIZE_FOR_JSON) { - initialStateScript = initialStateStr; - } else { - // embed large initial state as text and parse with JSON.parse instead. - const dataId = `${name}-initial-state-${Date.now()}-${++INITIAL_STATE_TAG_ID}`; - dynInitialState = ` -`; - initialStateScript = `JSON.parse(document.getElementById("${dataId}").innerHTML)`; - } - - const inlineStr = props.inline ? `inline:${props.inline},\n ` : ""; - const groupStr = props.group ? `group:"${props.group}",\n ` : ""; - outputSpot.add(` -${dynInitialState} -`); - }; - - const handleError = err => { - if (process.env.NODE_ENV !== "production") { - const stack = util.removeCwd(err.stack); - const msg = makeDevDebugMessage( - `Error: SSR subapp ${name} failed -${stack}`, - false // SSR failure is likely an issue in user code, don't show link to report bug - ); - console.error(msg); // eslint-disable-line - outputSpot.add(makeDevDebugHtml(msg)); - } else if (request && request.log) { - request.log(["error"], { msg: `SSR subapp ${name} failed`, err }); - } - }; - - let startTime; - - const closeOutput = () => { - ssrInfo.isDone = true; - if (props.timestamp) { - const now = Date.now(); - outputSpot.add(``); - } - - outputSpot.close(); - context.user.numOfSubapps--; - if (context.user.numOfSubapps === 0 && context.user.headEntries) { - context.user.headEntries.close(); - context.user.headEntries = undefined; - } - }; - - ssrInfo.done = () => { - if (!ssrInfo.isDone) { - closeOutput(); - } - }; - - const processSubapp = async () => { - context.user.numOfSubapps++; - const { bundles, scripts, preLoads } = await prepareSubAppSplitBundles(context); - outputSpot.add(`${comment}`); - if (bundles.length > 0) { - outputSpot.add(`${scripts} - -`); - } - if (preLoads.length > 0) { - context.user.headEntries.add("\n"); - context.user.headEntries.add(preLoads); - context.user.headEntries.add("\n"); - } - - if (props.serverSideRendering) { - if (!context.user[`prepare-grp-${props.group}`]) { - context.user[`prepare-grp-${props.group}`] = Date.now(); - } - - if ( - subAppLoadTime === 0 || // subapp has not been loaded yet, so must load once - !request.app.webpackDev || - (request.app.webpackDev && subAppLoadTime < request.app.webpackDev.compileTime) - ) { - subAppLoadTime = _.get(request, "app.webpackDev.compileTime", Date.now()); - loadSubApp(); - } - const ref = { - context, - subApp, - subAppServer, - options: props, - ssrGroups - }; - const lib = (ssrInfo.lib = util.getFramework(ref)); - ssrInfo.awaitData = lib.handlePrepare(); - - ssrInfo.defer = true; - - if (!props.inline) { - ssrInfo.renderSSR = async () => { - try { - outputSSRContent(await lib.handleSSR(ref), lib.initialStateStr); - } catch (err) { - handleError(err); - } finally { - closeOutput(); - } - }; - } else { - ssrInfo.saveSSRInfo = () => { - if (ssrInfo.saved) { - return; - } - ssrInfo.saved = true; - try { - // output load without SSR content - outputSSRContent("", lib.initialStateStr); - _.set(request.app, ["xarcInlineSSR", name], ssrInfo); - } catch (err) { - handleError(err); - } finally { - closeOutput(); - } - }; - } - } else { - outputSSRContent(""); - } - }; - - const asyncProcess = async () => { - if (props.timestamp) { - startTime = Date.now(); - outputSpot.add(``); - } - - try { - await processSubapp(); - ssrInfo.ready.resolve(); - } catch (err) { - handleError(err); - } finally { - if (!ssrInfo.defer) { - closeOutput(); - } - } - }; - - process.nextTick(asyncProcess); - } - }; -};