subapp-web
Version:
Electrode subapp web support
476 lines (423 loc) • 16.6 kB
JavaScript
/* 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("node-fetch");
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 `<h1 style="background-color: red">DEV ERROR</h1>
<p><pre style="color: red">${msg}</pre></p>
<!-- ${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 <header> 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<!-- subapp load ${name} -->\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 });
const { scriptNonce } = util.getNonceValue(setupContext.routeOptions);
retrieveUrl(bundleUrl) //ToDo: Replace node-fetch with inbuilt fetch api when supports node 18+ versions
.then(async resp => {
const body = await resp.text();
if (resp.status === 200) {
resolve(`<script${scriptNonce}>/*${name}*/${body}</script>`);
} else {
const msg = makeDevDebugMessage(
`Error: fail to retrieve subapp bundle from '${bundleUrl}' for inlining in index HTML
Response: ${body}`
);
console.error(msg); // eslint-disable-line
resolve(makeDevDebugHtml(msg));
}
})
.catch(err => {
const msg = makeDevDebugMessage(
`Error: fail to retrieve subapp bundle from '${bundleUrl}' for inlining in index HTML
Response: ${err}`
);
console.error(msg); // eslint-disable-line
resolve(makeDevDebugHtml(msg));
});
});
};
//
// 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;
const { scriptNonce, styleNonce } = util.getNonceValue(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 = `<script${scriptNonce}>/*${name}*/${src}</script>`;
} else if (ext === ".css") {
inlineSubAppJs = `<style${styleNonce} id="${name}">${src}</style>`;
} 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];
// Add async chunk if available to entrypoints.
const asyncChunk = assets.chunks.find(chunk =>
chunk.names.includes(`${entryName.replace("/", "_")}~.bootstrap`)
);
if (asyncChunk) {
entryPoints.push(asyncChunk.id);
}
const cdnJsBundles = util.getCdnJsBundles(assets, setupContext.routeOptions);
const bundles = entryPoints.filter(ep => !includedBundles[ep]);
const headSplits = [];
const { scriptNonce, styleNonce } = util.getNonceValue(context.user.routeOptions);
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(
`<link${scriptNonce}
rel="preload"
href="${jsBundle}"
as="script">`
);
}
a.push(
`<script${scriptNonce}
src="${jsBundle}"
async></script>`
);
} else if (ext === ".css") {
if (context.user.headEntries) {
headSplits.push(
`<link${styleNonce}
rel="stylesheet"
href="${jsBundle}">`
);
} else {
a.push(
`<link${styleNonce}
rel="stylesheet"
href="${jsBundle}">`
);
}
} else {
a.push(`<!-- UNKNOWN bundle extension ${jsBundle} -->`);
}
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, routeOptions } = 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(`<div id="${props.elementId}">`);
outputSpot.add(ssrContent); // must add by itself since this could be a stream
outputSpot.add(`</div>`);
} else {
outputSpot.add("<!-- inline or no elementId for starting subApp on load -->");
if (ssrContent) {
outputSpot.add("\n");
outputSpot.add(ssrContent);
outputSpot.add("\n");
}
}
let dynInitialState = "";
let initialStateScript;
const { scriptNonce = "" } = util.getNonceValue(context.user.routeOptions);
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 = `<script${scriptNonce}
type="application/json"
id="${dataId}">
${jsesc(JSON.parse(initialStateStr), {
json: true,
isScriptContext: true,
wrap: true
})}
</script>
`;
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}
<script${scriptNonce} >${xarc}.startSubAppOnLoad({
name:"${name}",
${elementId}serverSideRendering:${Boolean(props.serverSideRendering)},
${inlineStr}${groupStr}clientProps:${clientProps},
getInitialState:function(){return ${initialStateScript}}
});</script>
`);
};
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(`<!-- time: ${now} diff: ${now - startTime} -->`);
}
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 () => {
const namespace = setupContext.routeOptions.namespace;
const { scriptNonce } = util.getNonceValue(context.user.routeOptions);
context.user.numOfSubapps++;
const { bundles, scripts, preLoads } = await prepareSubAppSplitBundles(context);
outputSpot.add(`${comment}`);
if (bundles.length > 0) {
outputSpot.add(`${scripts}<script${scriptNonce} >
${xarc}.markBundlesLoaded(${JSON.stringify(bundles)}${
namespace ? ", " + JSON.stringify(namespace) : ""
});</script>
`);
}
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 = routeOptions.initialize
? Promise.resolve(routeOptions.initialize(request)).then(() => lib.handlePrepare())
: 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(`<!-- time: ${startTime} -->`);
}
try {
await processSubapp();
ssrInfo.ready.resolve();
} catch (err) {
handleError(err);
} finally {
if (!ssrInfo.defer) {
closeOutput();
}
}
};
process.nextTick(asyncProcess);
}
};
};
;