From c150185075d8400edfaece4cb5c014657b34bd77 Mon Sep 17 00:00:00 2001 From: Yisheng Jiang Date: Tue, 23 Jun 2020 14:55:09 -0700 Subject: [PATCH] extract tokens from electrode-react-webapp into @xarc/webapp for subapp-server (#1681) Co-authored-by: vn08447 --- packages/subapp-server/lib/register-routes.js | 2 +- .../subapp-server/lib/setup-hapi-routes.js | 2 +- packages/subapp-server/package.json | 8 +- packages/subapp-server/template.js | 2 +- .../test/spec/setup-hapi-routes.spec.js | 2 +- packages/xarc-webapp/config/test/setup.js | 30 ++ packages/xarc-webapp/package.json | 44 ++ packages/xarc-webapp/src/async-template.js | 409 ++++++++++++++++++ packages/xarc-webapp/src/group-scripts.js | 61 +++ packages/xarc-webapp/src/http-status.js | 13 + packages/xarc-webapp/src/index.html | 36 ++ packages/xarc-webapp/src/react-webapp.js | 334 ++++++++++++++ packages/xarc-webapp/src/react/content.js | 94 ++++ .../src/react/handlers/prefetch-bundles.js | 13 + .../xarc-webapp/src/react/token-handlers.js | 241 +++++++++++ packages/xarc-webapp/tsconfig.json | 25 ++ packages/xarc-webapp/xclap.ts | 2 + 17 files changed, 1311 insertions(+), 7 deletions(-) create mode 100644 packages/xarc-webapp/config/test/setup.js create mode 100644 packages/xarc-webapp/package.json create mode 100644 packages/xarc-webapp/src/async-template.js create mode 100644 packages/xarc-webapp/src/group-scripts.js create mode 100644 packages/xarc-webapp/src/http-status.js create mode 100644 packages/xarc-webapp/src/index.html create mode 100644 packages/xarc-webapp/src/react-webapp.js create mode 100644 packages/xarc-webapp/src/react/content.js create mode 100644 packages/xarc-webapp/src/react/handlers/prefetch-bundles.js create mode 100644 packages/xarc-webapp/src/react/token-handlers.js create mode 100644 packages/xarc-webapp/tsconfig.json create mode 100644 packages/xarc-webapp/xclap.ts diff --git a/packages/subapp-server/lib/register-routes.js b/packages/subapp-server/lib/register-routes.js index 9edbdf29d..bc20c9a4e 100644 --- a/packages/subapp-server/lib/register-routes.js +++ b/packages/subapp-server/lib/register-routes.js @@ -5,7 +5,7 @@ const assert = require("assert"); const _ = require("lodash"); const HttpStatus = require("./http-status"); -const { ReactWebapp } = require("electrode-react-webapp"); +const { ReactWebapp } = require("@xarc/webapp"); const { errorResponse, resolveChunkSelector, updateFullTemplate } = require("./utils"); const HttpStatusCodes = require("http-status-codes"); diff --git a/packages/subapp-server/lib/setup-hapi-routes.js b/packages/subapp-server/lib/setup-hapi-routes.js index 54deb0070..105bf0300 100644 --- a/packages/subapp-server/lib/setup-hapi-routes.js +++ b/packages/subapp-server/lib/setup-hapi-routes.js @@ -15,7 +15,7 @@ const Boom = require("@hapi/boom"); const HttpStatus = require("./http-status"); const readFile = util.promisify(Fs.readFile); const xaa = require("xaa"); -const { ReactWebapp } = require("electrode-react-webapp"); +const { ReactWebapp } = require("@xarc/webapp"); const subAppUtil = require("subapp-util"); const registerRoutes = require("./register-routes"); diff --git a/packages/subapp-server/package.json b/packages/subapp-server/package.json index 6fc1f5e83..b4fbe40f6 100644 --- a/packages/subapp-server/package.json +++ b/packages/subapp-server/package.json @@ -28,7 +28,8 @@ ], "dependencies": { "@hapi/boom": "^7.4.1", - "electrode-react-webapp": "^3.8.9", + "@xarc/webapp": "^1.0.0", + "@xarc/jsx-renderer": "^1.0.0", "filter-scan-dir": "^1.0.9", "http-status-codes": "^1.3.0", "optional-require": "^1.0.0", @@ -48,8 +49,9 @@ }, "fyn": { "dependencies": { - "electrode-react-webapp": "../electrode-react-webapp", - "subapp-util": "../subapp-util" + "@xarc/webapp": "../xarc-webapp", + "subapp-util": "../subapp-util", + "@xarc/jsx-renderer": "../xarc-jsx-renderer" } }, "nyc": { diff --git a/packages/subapp-server/template.js b/packages/subapp-server/template.js index fdd8bd826..3c5bae302 100644 --- a/packages/subapp-server/template.js +++ b/packages/subapp-server/template.js @@ -1,3 +1,3 @@ "use strict"; -module.exports = require("electrode-react-webapp/lib/jsx"); +module.exports = require("@xarc/jsx-renderer"); diff --git a/packages/subapp-server/test/spec/setup-hapi-routes.spec.js b/packages/subapp-server/test/spec/setup-hapi-routes.spec.js index 12de2f0ec..2c91aa440 100644 --- a/packages/subapp-server/test/spec/setup-hapi-routes.spec.js +++ b/packages/subapp-server/test/spec/setup-hapi-routes.spec.js @@ -5,7 +5,7 @@ const { setupSubAppHapiRoutes } = require("../../lib/setup-hapi-routes"); const Path = require("path"); const electrodeServer = require("electrode-server"); const sinon = require("sinon"); -const { ReactWebapp } = require("electrode-react-webapp"); +const { ReactWebapp } = require("@xarc/webapp"); describe("setupSubAppHapiRoutes", () => { let server; diff --git a/packages/xarc-webapp/config/test/setup.js b/packages/xarc-webapp/config/test/setup.js new file mode 100644 index 000000000..2b0979685 --- /dev/null +++ b/packages/xarc-webapp/config/test/setup.js @@ -0,0 +1,30 @@ +"use strict"; + +function tryRequire(path) { + try { + return require(path); + } catch { + return undefined; + } +} + +// Chai setup. +const chai = tryRequire("chai"); +if (!chai) { + console.log(` +mocha setup: chai is not found. Not setting it up for mocha. + To setup chai for your mocha test, run 'clap mocha'.`); +} else { + const sinonChai = tryRequire("sinon-chai"); + + if (!sinonChai) { + console.log(` +mocha setup: sinon-chai is not found. Not setting it up for mocha. + To setup sinon-chai for your mocha test, run 'clap mocha'.`); + } else { + chai.use(sinonChai); + } + + // Exports + global.expect = chai.expect; +} diff --git a/packages/xarc-webapp/package.json b/packages/xarc-webapp/package.json new file mode 100644 index 000000000..3409371f5 --- /dev/null +++ b/packages/xarc-webapp/package.json @@ -0,0 +1,44 @@ +{ + "name": "xarc-webapp", + "version": "1.0.1", + "description": "", + "main": "dist/index.js", + "directories": { + "lib": "lib", + "test": "test" + }, + "dependencies": { + "mocha": "^8.0.1", + "@xarc/jsx-renderer": "file:../xarc-jsx-renderer", + "ts-node": "^8.10.2", + "typescript": "^3.9.5", + "xclap": "^0.2.51" + }, + "devDependencies": { + "@types/chai": "^4.2.11", + "@types/mocha": "^7.0.2", + "@types/sinon": "^9.0.0", + "@types/sinon-chai": "^3.2.4", + "babel-eslint": "^10.1.0", + "chai": "^4.2.0", + "eslint": "^6.8.0", + "eslint-config-walmart": "^2.2.1", + "eslint-plugin-filenames": "^1.1.0", + "eslint-plugin-jsdoc": "^21.0.0", + "mocha": "^7.1.0", + "nyc": "^15.0.0", + "sinon": "^7.2.6", + "sinon-chai": "^3.3.0" + }, + "scripts": { + "test": "mocha" + }, + "author": "", + "license": "ISC", + "mocha": { + "require": [ + "./config/test/setup.js" + ], + "recursive": true + } +} diff --git a/packages/xarc-webapp/src/async-template.js b/packages/xarc-webapp/src/async-template.js new file mode 100644 index 000000000..eb8976d18 --- /dev/null +++ b/packages/xarc-webapp/src/async-template.js @@ -0,0 +1,409 @@ +"use strict"; + +/* eslint-disable max-params, max-statements, no-constant-condition, no-magic-numbers */ + +const assert = require("assert"); +const Fs = require("fs"); +const RenderContext = require("./render-context"); +const loadHandler = require("./load-handler"); +const Renderer = require("./renderer"); +const { resolvePath } = require("./utils"); +const Token = require("./token"); +const stringArray = require("string-array"); +const _ = require("lodash"); +const Path = require("path"); +const Promise = require("bluebird"); + +const { TEMPLATE_DIR } = require("./symbols"); + +const tokenTags = { + "") + }, + "/*--%{": { + // for tokens in script and style + open: "\\/\\*--[ \n]*%{", + close: new RegExp("}--\\*/") + } +}; + +const tokenOpenTagRegex = new RegExp( + Object.keys(tokenTags) + .map(x => `(${tokenTags[x].open})`) + .join("|") +); + +class AsyncTemplate { + constructor(options) { + this._options = options; + this._tokenHandlers = [].concat(this._options.tokenHandlers).filter(x => x); + this._handlersMap = {}; + // the same context that gets passed to each token handler's setup function + this._handlerContext = _.merge( + { + user: { + // set routeOptions in user also for consistency + routeOptions: options.routeOptions + } + }, + options + ); + this._initializeTemplate(options.htmlFile); + } + + initializeRenderer(reset) { + if (reset || !this._renderer) { + this._initializeTokenHandlers(this._tokenHandlers); + this._applyTokenLoad(); + this._renderer = new Renderer({ + insertTokenIds: this._options.insertTokenIds, + htmlTokens: this._tokens, + tokenHandlers: this._tokenHandlers + }); + } + } + + get tokens() { + return this._tokens; + } + + get handlersMap() { + return this._handlersMap; + } + + render(options) { + const context = new RenderContext(options, this); + + return Promise.each(this._beforeRenders, r => r.beforeRender(context)) + .then(() => { + return this._renderer.render(context); + }) + .then(result => { + return Promise.each(this._afterRenders, r => r.afterRender(context)).then(() => { + context.result = context.isVoidStop ? context.voidResult : result; + + return context; + }); + }); + } + + _findTokenIndex(id, str, index, instance = 0, msg = "AsyncTemplate._findTokenIndex") { + let found; + + if (id) { + found = this.findTokensById(id, instance + 1); + } else if (str) { + found = this.findTokensByStr(str, instance + 1); + } else if (!Number.isInteger(index)) { + throw new Error(`${msg}: invalid id, str, and index`); + } else if (index < 0 || index >= this._tokens.length) { + throw new Error(`${msg}: index ${index} is out of range.`); + } else { + return index; + } + + if (found.length === 0) return false; + + return found[instance].index; + } + + // + // add tokens at first|last position of the tokens, + // or add tokens before|after token at {id}[instance] or {index} + // ^^^ {insert} + // - Note that item indexes will change after add + // + // returns: + // - number of tokens removed + // - false if nothing was removed + // throws: + // - if id and index are invalid + // - if {insert} is invalid + // + addTokens({ insert = "after", id, index, str, instance = 0, tokens }) { + const create = tk => { + return new Token( + tk.token, + -1, + typeof tk.props === "string" ? this._parseTokenProps(tk.props) : tk.props + ); + }; + + if (insert === "first") { + this._tokens.unshift(...tokens.map(create)); + return 0; + } + + if (insert === "last") { + const x = this._tokens.length; + this._tokens.push(...tokens.map(create)); + return x; + } + + index = this._findTokenIndex(id, str, index, instance, "AsyncTemplate.addTokens"); + if (index === false) return false; + + if (insert === "before") { + this._tokens.splice(index, 0, ...tokens.map(create)); + return index; + } + + if (insert === "after") { + index++; + this._tokens.splice(index, 0, ...tokens.map(create)); + return index; + } + + throw new Error( + `AsyncTemplate.addTokens: insert "${insert}" is not valid, must be first|before|after|last` + ); + } + + // + // remove {count} tokens before|after token at {id}[instance] or {index} + // ^^^ {remove} + // - if removeSelf is true then the token at {id}[instance] or {index} is included for removal + // returns: + // - array of tokens removed + // throws: + // - if id and index are invalid + // - if {remove} is invalid + // + removeTokens({ remove = "after", removeSelf = true, id, str, index, instance = 0, count = 1 }) { + assert(count > 0, `AsyncTemplate.removeTokens: count ${count} must be > 0`); + + index = this._findTokenIndex(id, str, index, instance, "AsyncTemplate.removeTokens"); + if (index === false) return false; + + const offset = removeSelf ? 0 : 1; + + if (remove === "before") { + let newIndex = index + 1 - count - offset; + if (newIndex < 0) { + newIndex = 0; + count = index + 1 - offset; + } + return this._tokens.splice(newIndex, count); + } else if (remove === "after") { + return this._tokens.splice(index + offset, count); + } else { + throw new Error(`AsyncTemplate.removeTokens: remove "${remove}" must be before|after`); + } + } + + findTokensById(id, count = Infinity) { + if (!Number.isInteger(count)) count = this._tokens.length; + + const found = []; + + for (let index = 0; index < this._tokens.length && found.length < count; index++) { + const token = this._tokens[index]; + if (token.id === id) { + found.push({ index, token }); + } + } + + return found; + } + + findTokensByStr(matcher, count = Infinity) { + if (!Number.isInteger(count)) count = this._tokens.length; + + const found = []; + + let match; + + if (typeof matcher === "string") { + match = str => str.indexOf(matcher) >= 0; + } else if (matcher && matcher.constructor.name === "RegExp") { + match = str => str.match(matcher); + } else { + throw new Error("AsyncTemplate.findTokensByStr: matcher must be a string or RegExp"); + } + + for (let index = 0; index < this._tokens.length && found.length < count; index++) { + const token = this._tokens[index]; + if (token.hasOwnProperty("str") && match(token.str)) { + found.push({ index, token }); + } + } + + return found; + } + + /* + * break up the template into a list of literal strings and the tokens between them + * + * - each item is of the form: + * + * { str: "literal string" } + * + * or a Token object + */ + + _parseTemplate(template, filepath) { + const tokens = []; + const templateDir = Path.dirname(filepath); + + let pos = 0; + + const parseFail = msg => { + const lineCount = [].concat(template.substring(0, pos).match(/\n/g)).length + 1; + const lastNLIx = template.lastIndexOf("\n", pos); + const lineCol = pos - lastNLIx; + msg = msg.replace(/\n/g, "\\n"); + const pfx = `electrode-react-webapp: ${filepath}: at line ${lineCount} col ${lineCol}`; + throw new Error(`${pfx} - ${msg}`); + }; + + let subTmpl = template; + while (true) { + const openMatch = subTmpl.match(tokenOpenTagRegex); + if (openMatch) { + pos += openMatch.index; + + if (openMatch.index > 0) { + const str = subTmpl.substring(0, openMatch.index).trim(); + // if there are text between a close tag and an open tag, then consider + // that as plain HTML string + if (str) tokens.push({ str }); + } + + const tokenOpenTag = openMatch[0].replace(/[ \n]/g, ""); + const tokenCloseTag = tokenTags[tokenOpenTag].close; + subTmpl = subTmpl.substring(openMatch.index + openMatch[0].length); + const closeMatch = subTmpl.match(tokenCloseTag); + + if (!closeMatch) { + parseFail(`Can't find token close tag for '${openMatch[0]}'`); + } + + const tokenBody = subTmpl + .substring(0, closeMatch.index) + .trim() + .split("\n") + .map(x => x.trim()) + // remove empty and comment lines that start with "//" + .filter(x => x && !x.startsWith("//")) + .join(" "); + + const consumedCount = closeMatch.index + closeMatch[0].length; + subTmpl = subTmpl.substring(consumedCount); + + const token = tokenBody.split(" ", 1)[0]; + if (!token) { + parseFail(`empty token body`); + } + + const tokenProps = tokenBody.substring(token.length).trim(); + + try { + const props = this._parseTokenProps(tokenProps); + props[TEMPLATE_DIR] = templateDir; + + tokens.push(new Token(token, pos, props)); + pos += openMatch[0].length + consumedCount; + } catch (e) { + parseFail(`'${tokenBody}' has malformed prop: ${e.message};`); + } + } else { + const str = subTmpl.trim(); + if (str) tokens.push({ str }); + break; + } + } + + return tokens; + } + + _parseTokenProps(str) { + // check if it's JSON object by looking for "{" + if (str[0] === "{") { + return JSON.parse(str); + } + + const props = {}; + + while (str) { + const m1 = str.match(/([\w]+)=(.)/); + assert(m1 && m1[1], "name must be name=Val"); + const name = m1[1]; + + if (m1[2] === `[`) { + // treat as name=[str1, str2] + str = str.substring(m1[0].length - 1); + const r = stringArray.parse(str, true); + props[name] = r.array; + str = r.remain.trim(); + } else if (m1[2] === `'` || m1[2] === `"` || m1[2] === "`") { + str = str.substring(m1[0].length); + const m2 = str.match(new RegExp(`([^${m1[2]}]+)${m1[2]}`)); + assert(m2, `mismatch quote ${m1[2]}`); + props[name] = m2[1]; + str = str.substring(m2[0].length).trim(); + } else if (m1[2] === " ") { + // empty + props[name] = ""; + str = str.substring(m1[0].length).trim(); + } else { + str = str.substring(m1[0].length - 1); + const m2 = str.match(/([^ ]*)/); // matching name=Prop + props[name] = JSON.parse(m2[1]); + str = str.substring(m2[0].length).trim(); + } + } + + return props; + } + + _initializeTemplate(filename) { + const filepath = resolvePath(filename); + const html = Fs.readFileSync(filepath).toString(); + this._tokens = this._parseTemplate(html, filepath); + } + + _loadTokenHandler(path) { + const mod = loadHandler(path); + return mod(this._handlerContext, this); + } + + _applyTokenLoad() { + this._tokens.forEach(x => { + if (x.load) { + x.load(this._options, this); + } + }); + } + + _initializeTokenHandlers(filenames) { + this._tokenHandlers = filenames.map(fname => { + let handler; + if (typeof fname === "string") { + handler = this._loadTokenHandler(fname); + } else { + handler = fname; + assert(handler.name, "electrode-react-webapp AsyncTemplate token handler missing name"); + } + if (!handler.name) { + handler = { + name: fname, + tokens: handler + }; + } + assert(handler.tokens, "electrode-react-webapp AsyncTemplate token handler missing tokens"); + assert( + !this._handlersMap.hasOwnProperty(handler.name), + `electrode-react-webapp AsyncTemplate token handlers map already contains ${handler.name}` + ); + this._handlersMap[handler.name] = handler; + return handler; + }); + + this._beforeRenders = this._tokenHandlers.filter(x => x.beforeRender); + this._afterRenders = this._tokenHandlers.filter(x => x.afterRender); + } +} + +module.exports = AsyncTemplate; diff --git a/packages/xarc-webapp/src/group-scripts.js b/packages/xarc-webapp/src/group-scripts.js new file mode 100644 index 000000000..8b7dc055b --- /dev/null +++ b/packages/xarc-webapp/src/group-scripts.js @@ -0,0 +1,61 @@ +"use strict"; + +const _ = require("lodash"); + +function joinScripts(acc) { + if (acc.current) { + acc.scripts.push( + acc.src + ? acc.current + : acc.current + .map(x => { + x = _.trim(x); + return x.endsWith(";") ? x : `${x};`; + }) + .join("\n\n") + ); + acc.current = undefined; + } +} +/* + * Take an array of strings and objects, where strings are JavaScript and objects + * contain src URL pointing to a JavaScript, and combine all consecutive strings into + * a single one. + * + * The purpose of this is to avoid generating mutiple + * + * + * The output will be: + * + * + */ +module.exports = function groupScripts(data) { + const output = data.filter(x => x).reduce( + (acc, x) => { + const update = src => { + if (acc.src !== src || !acc.current) { + joinScripts(acc); + acc.current = [x]; + acc.src = src; + } else { + acc.current.push(x); + } + }; + + update(!!x.src); + + return acc; + }, + { src: false, scripts: [] } + ); + + joinScripts(output); + + return output; +}; diff --git a/packages/xarc-webapp/src/http-status.js b/packages/xarc-webapp/src/http-status.js new file mode 100644 index 000000000..41582d8d1 --- /dev/null +++ b/packages/xarc-webapp/src/http-status.js @@ -0,0 +1,13 @@ +"use strict"; + +const HttpStatusCodes = require("http-status-codes"); + +module.exports = { + // Status codes where we want to redirect the user + redirect: { + [HttpStatusCodes.MOVED_PERMANENTLY]: true, + [HttpStatusCodes.MOVED_TEMPORARILY]: true, + [HttpStatusCodes.PERMANENT_REDIRECT]: true, + [HttpStatusCodes.TEMPORARY_REDIRECT]: true, + }, +}; diff --git a/packages/xarc-webapp/src/index.html b/packages/xarc-webapp/src/index.html new file mode 100644 index 000000000..cdb99e53c --- /dev/null +++ b/packages/xarc-webapp/src/index.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + diff --git a/packages/xarc-webapp/src/react-webapp.js b/packages/xarc-webapp/src/react-webapp.js new file mode 100644 index 000000000..ff625c9f9 --- /dev/null +++ b/packages/xarc-webapp/src/react-webapp.js @@ -0,0 +1,334 @@ +import * as _ from "lodash"; +import * as Path from "path"; +import * as assert from "assert"; +import * as AsyncTemplate from "./async-template"; +import { JsxRenderer } from "@xarc/jsx-renderer"; + +const { + getOtherStats, + getOtherAssets, + resolveChunkSelector, + loadAssetsFromStats, + getStatsPath, + invokeTemplateProcessor, + makeDevBundleBase, +} = require("./utils"); + +const otherStats = getOtherStats(); + +function initializeTemplate( + { htmlFile, templateFile, tokenHandlers, cacheId, cacheKey, options }, + routeOptions +) { + const tmplFile = templateFile || htmlFile; + cacheKey = cacheKey || (cacheId && `${tmplFile}#${cacheId}`) || tmplFile; + + let asyncTemplate = routeOptions._templateCache[cacheKey]; + if (asyncTemplate) { + return asyncTemplate; + } + + if (options) { + routeOptions = Object.assign({}, routeOptions, options); + } + + const userTokenHandlers = [] + .concat( + tokenHandlers, + routeOptions.tokenHandler, + routeOptions.tokenHandlers + ) + .filter((x) => x); + + let finalTokenHandlers = userTokenHandlers; + + // Inject the built-in react/token-handlers if it is not in user's handlers + // and replaceTokenHandlers option is false + if (!routeOptions.replaceTokenHandlers) { + const reactTokenHandlers = Path.join(__dirname, "react/token-handlers"); + finalTokenHandlers = + userTokenHandlers.indexOf(reactTokenHandlers) < 0 + ? [reactTokenHandlers].concat(userTokenHandlers) + : userTokenHandlers; + } + + if (!templateFile) { + asyncTemplate = new AsyncTemplate({ + htmlFile, + tokenHandlers: finalTokenHandlers.filter((x) => x), + insertTokenIds: routeOptions.insertTokenIds, + routeOptions, + }); + + invokeTemplateProcessor(asyncTemplate, routeOptions); + asyncTemplate.initializeRenderer(); + } else { + const templateFullPath = require.resolve(tmplFile); + const template = require(tmplFile); + asyncTemplate = new JsxRenderer({ + templateFullPath: Path.dirname(templateFullPath), + template: _.get(template, "default", template), + tokenHandlers: finalTokenHandlers.filter((x) => x), + insertTokenIds: routeOptions.insertTokenIds, + routeOptions, + }); + asyncTemplate.initializeRenderer(); + } + + return (routeOptions._templateCache[cacheKey] = asyncTemplate); +} + +function makeRouteHandler(routeOptions) { + routeOptions._templateCache = {}; + let defaultSelection; + + if (routeOptions.templateFile) { + defaultSelection = { + templateFile: + typeof routeOptions.templateFile === "string" + ? Path.resolve(routeOptions.templateFile) + : Path.join(__dirname, "../template/index"), + }; + } else { + defaultSelection = { htmlFile: routeOptions.htmlFile }; + } + + const render = (options, templateSelection) => { + let selection = templateSelection || defaultSelection; + if ( + templateSelection && + !templateSelection.templateFile && + !templateSelection.htmlFile + ) { + selection = Object.assign({}, templateSelection, defaultSelection); + } + const asyncTemplate = initializeTemplate(selection, routeOptions); + return asyncTemplate.render(options); + }; + + return (options) => { + if (routeOptions.selectTemplate) { + const selection = routeOptions.selectTemplate( + options.request, + routeOptions + ); + + if (selection && selection.then) { + return selection.then((x) => render(options, x)); + } + + return render(options, selection); + } + + const asyncTemplate = initializeTemplate(defaultSelection, routeOptions); + return asyncTemplate.render(options); + }; +} + +const setupOptions = (options) => { + const https = + process.env.WEBPACK_DEV_HTTPS && process.env.WEBPACK_DEV_HTTPS !== "false"; + + const pluginOptionsDefaults = { + pageTitle: "Untitled Electrode Web Application", + webpackDev: process.env.WEBPACK_DEV === "true", + renderJS: true, + serverSideRendering: true, + htmlFile: Path.join(__dirname, "index.html"), + devServer: { + protocol: https ? "https" : "http", + host: + process.env.WEBPACK_DEV_HOST || process.env.WEBPACK_HOST || "localhost", + port: process.env.WEBPACK_DEV_PORT || "2992", + https, + }, + unbundledJS: { + enterHead: [], + preBundle: [], + postBundle: [], + }, + paths: {}, + stats: "dist/server/stats.json", + otherStats, + iconStats: "dist/server/iconstats.json", + criticalCSS: "dist/js/critical.css", + buildArtifacts: ".build", + prodBundleBase: "/js/", + cspNonceValue: undefined, + }; + + const pluginOptions = _.defaultsDeep({}, options, pluginOptionsDefaults); + const chunkSelector = resolveChunkSelector(pluginOptions); + const devBundleBase = makeDevBundleBase(pluginOptions.devServer); + const statsPath = getStatsPath( + pluginOptions.stats, + pluginOptions.buildArtifacts + ); + + const assets = loadAssetsFromStats(statsPath); + const otherAssets = getOtherAssets(pluginOptions); + pluginOptions.__internals = _.defaultsDeep({}, pluginOptions.__internals, { + assets, + otherAssets, + chunkSelector, + devBundleBase, + }); + + return pluginOptions; +}; + +const pathSpecificOptions = [ + "htmlFile", + "templateFile", + "insertTokenIds", + "pageTitle", + "selectTemplate", + "responseForBadStatus", + "responseForError", +]; + +const setupPathOptions = (routeOptions, path) => { + const pathData = _.get(routeOptions, ["paths", path], {}); + const pathOverride = _.get( + routeOptions, + ["paths", path, "overrideOptions"], + {} + ); + const pathOptions = pathData.options; + return _.defaultsDeep( + _.pick(pathData, pathSpecificOptions), + { + tokenHandler: [].concat(routeOptions.tokenHandler, pathData.tokenHandler), + tokenHandlers: [].concat( + routeOptions.tokenHandlers, + pathData.tokenHandlers + ), + }, + pathOptions, + _.omit(pathOverride, "paths"), + routeOptions + ); +}; + +// +// The route path can supply: +// +// - a literal string +// - a function +// - an object +// +// If it's an object: +// -- if it doesn't contain content, then it's assume to be the content. +// +// If it contains content, then it can contain: +// +// - method: HTTP method for the route +// - config: route config (applicable for framework like Hapi) +// - content: second level field to define content +// +// content can be: +// +// - a literal string +// - a function +// - an object +// +// If content is an object, it can contain module, a path to the JS module to require +// to load the content. +// +const resolveContent = (pathData, xrequire) => { + const resolveTime = Date.now(); + + let content = pathData; + + // If it's an object, see if contains content field + if (_.isObject(pathData) && pathData.hasOwnProperty("content")) { + content = pathData.content; + } + + if (!content && !_.isString(content)) return null; + + // content has module field, require it. + if (!_.isString(content) && !_.isFunction(content) && content.module) { + const mod = content.module.startsWith(".") + ? Path.resolve(content.module) + : content.module; + + xrequire = xrequire || require; + + try { + return { + fullPath: xrequire.resolve(mod), + xrequire, + resolveTime, + content: xrequire(mod), + }; + } catch (error) { + const msg = `electrode-react-webapp: load SSR content ${mod} failed - ${error.message}`; + console.error(msg, "\n", error); // eslint-disable-line + return { + fullPath: null, + error, + resolveTime, + content: msg, + }; + } + } + + return { + fullPath: null, + resolveTime, + content, + }; +}; + +const getContentResolver = (registerOptions, pathData, path) => { + let resolved; + + const resolveWithDev = (webpackDev, xrequire) => { + if (!webpackDev.valid) { + resolved = resolveContent(""); + } else if (webpackDev.hasErrors) { + resolved = resolveContent(""); + } else if (!resolved || resolved.resolveTime < webpackDev.compileTime) { + if (resolved && resolved.fullPath) { + delete resolved.xrequire.cache[resolved.fullPath]; + } + resolved = resolveContent(pathData, xrequire); + } + + return resolved.content; + }; + + return (webpackDev, xrequire) => { + if (webpackDev && registerOptions.serverSideRendering !== false) { + return resolveWithDev(webpackDev, xrequire); + } + + if (resolved) return resolved.content; + + if (registerOptions.serverSideRendering !== false) { + resolved = resolveContent(pathData); + assert( + resolved, + `You must define content for the webapp plugin path ${path}` + ); + } else { + resolved = { + content: { + status: 200, + html: "", + }, + }; + } + + return resolved.content; + }; +}; + +module.exports = { + setupOptions, + setupPathOptions, + makeRouteHandler, + resolveContent, + getContentResolver, +}; diff --git a/packages/xarc-webapp/src/react/content.js b/packages/xarc-webapp/src/react/content.js new file mode 100644 index 000000000..cc39d496c --- /dev/null +++ b/packages/xarc-webapp/src/react/content.js @@ -0,0 +1,94 @@ +"use strict"; + +const Fs = require("fs"); +const Path = require("path"); +const HttpStatusCodes = require("http-status-codes"); + +const Promise = require("bluebird"); + +const HTTP_ERROR_500 = 500; + +function getContent(renderSs, options, context) { + let userContent = options.content; + + // prepare user content for container of SSR output + + if (typeof userContent === "string") { + return Promise.resolve({ status: 200, html: userContent }); + } + + if (typeof userContent !== "function") return Promise.resolve(userContent); + + if (!renderSs) return Promise.resolve({ status: 200, html: "" }); + + // invoke user content as a function, which could return any content + // as static html or generated from react's renderToString + userContent = userContent(options.request, options, context); + + if (userContent.catch) { + // user function needs to generate the content async, so wait for it. + return userContent.catch(err => { + if (!err.status) err.status = HTTP_ERROR_500; + throw err; + }); + } + + return Promise.resolve(userContent); +} + +function transformOutput(result, context) { + const content = context.user.content; + if (content && content.status !== HttpStatusCodes.OK) { + return { + verbatim: content.verbatim, + status: content.status, + path: content.path, + store: content.store, + html: result + }; + } + + return result; +} + +const htmlifyScripts = (scripts, scriptNonce) => { + return scripts + .map(x => + typeof x === "string" + ? `${x}\n` + : x.map(n => ``).join("\n") + ) + .join("\n"); +}; + +const loadElectrodeDllAssets = routeOptions => { + const tag = process.env.NODE_ENV === "production" ? "" : ".dev"; + try { + const file = Path.resolve( + routeOptions.electrodeDllAssetsPath || `dist/electrode-dll-assets${tag}.json` + ); + return JSON.parse(Fs.readFileSync(file)); + } catch (err) { + return {}; + } +}; + +const makeElectrodeDllScripts = (dllAssets, nonce) => { + const scripts = []; + for (const modName in dllAssets) { + const cdnMapping = dllAssets[modName].cdnMapping; + for (const bundle in cdnMapping) { + scripts.push({ src: cdnMapping[bundle] }); + } + } + + return htmlifyScripts([scripts], nonce); +}; + +module.exports = { + getContent, + transformOutput, + htmlifyScripts, + loadElectrodeDllAssets, + makeElectrodeDllScripts +}; diff --git a/packages/xarc-webapp/src/react/handlers/prefetch-bundles.js b/packages/xarc-webapp/src/react/handlers/prefetch-bundles.js new file mode 100644 index 000000000..702f44f7d --- /dev/null +++ b/packages/xarc-webapp/src/react/handlers/prefetch-bundles.js @@ -0,0 +1,13 @@ +"use strict"; + +module.exports = context => { + const content = context.user.content; + if (!content || !content.prefetch) return ""; + + // allow user to include the `; + } else { + return content.prefetch; + } +}; diff --git a/packages/xarc-webapp/src/react/token-handlers.js b/packages/xarc-webapp/src/react/token-handlers.js new file mode 100644 index 000000000..8f7f25231 --- /dev/null +++ b/packages/xarc-webapp/src/react/token-handlers.js @@ -0,0 +1,241 @@ +"use strict"; + +/* eslint-disable max-statements, max-depth */ + +const groupScripts = require("../group-scripts"); + +const { + getIconStats, + getCriticalCSS, + getDevCssBundle, + getDevJsBundle, + getProdBundles, + processRenderSsMode, + getCspNonce, + getBundleJsNameByQuery, + isReadableStream +} = require("../utils"); + +const { + getContent, + transformOutput, + htmlifyScripts, + loadElectrodeDllAssets, + makeElectrodeDllScripts +} = require("./content"); + +const prefetchBundles = require("./handlers/prefetch-bundles"); +const CONTENT_MARKER = "SSR_CONTENT"; +const HEADER_BUNDLE_MARKER = "WEBAPP_HEADER_BUNDLES"; +const BODY_BUNDLE_MARKER = "WEBAPP_BODY_BUNDLES"; +const DLL_BUNDLE_MARKER = "WEBAPP_DLL_BUNDLES"; +const TITLE_MARKER = "PAGE_TITLE"; +const PREFETCH_MARKER = "PREFETCH_BUNDLES"; +const META_TAGS_MARKER = "META_TAGS"; +const CRITICAL_CSS_MARKER = "CRITICAL_CSS"; +const APP_CONFIG_DATA_MARKER = "APP_CONFIG_DATA"; +const WEBAPP_START_SCRIPT_MARKER = "WEBAPP_START_SCRIPT"; + +module.exports = function setup(handlerContext /*, asyncTemplate*/) { + const routeOptions = handlerContext.user.routeOptions; + + const WEBPACK_DEV = routeOptions.webpackDev; + const RENDER_JS = routeOptions.renderJS; + const RENDER_SS = routeOptions.serverSideRendering; + const assets = routeOptions.__internals.assets; + const otherAssets = routeOptions.__internals.otherAssets; + const devBundleBase = routeOptions.__internals.devBundleBase; + const prodBundleBase = routeOptions.prodBundleBase; + const chunkSelector = routeOptions.__internals.chunkSelector; + const iconStats = getIconStats(routeOptions.iconStats); + const criticalCSS = getCriticalCSS(routeOptions.criticalCSS); + + const routeData = { + WEBPACK_DEV, + RENDER_JS, + RENDER_SS, + assets, + otherAssets, + devBundleBase, + prodBundleBase, + chunkSelector, + iconStats, + criticalCSS + }; + + handlerContext.user.routeData = routeData; + + const bundleManifest = () => { + if (!assets.manifest) { + return ""; + } + + return WEBPACK_DEV + ? `${devBundleBase}${assets.manifest}` + : `${prodBundleBase}${assets.manifest}`; + }; + + const bundleJs = data => { + if (!data.renderJs) { + return ""; + } + if (WEBPACK_DEV) { + return data.devJSBundle; + } else if (data.jsChunk) { + const bundleJsName = getBundleJsNameByQuery(data, otherAssets); + return `${prodBundleBase}${bundleJsName}`; + } else { + return ""; + } + }; + + const INITIALIZE = context => { + const options = context.options; + const request = options.request; + const mode = options.mode; + const renderSs = processRenderSsMode(request, RENDER_SS, mode); + + return getContent(renderSs, options, context).then(content => { + if (content.render === false || content.html === undefined) { + return context.voidStop(content); + } + + const chunkNames = chunkSelector(request); + + const devCSSBundle = getDevCssBundle(chunkNames, routeData); + const devJSBundle = getDevJsBundle(chunkNames, routeData); + + const { jsChunk, cssChunk } = getProdBundles(chunkNames, routeData); + const { scriptNonce, styleNonce } = getCspNonce(request, routeOptions.cspNonceValue); + + const renderJs = RENDER_JS && mode !== "nojs"; + + context.user = { + request: options.request, + response: { + headers: {} + }, + routeOptions, + routeData, + content, + mode, + renderJs, + renderSs, + scriptNonce, + styleNonce, + chunkNames, + devCSSBundle, + devJSBundle, + jsChunk, + cssChunk + }; + + if (content.useStream || isReadableStream(content.html)) { + context.setMunchyOutput(); + } + + context.setOutputTransform(transformOutput); + + return context; + }); + }; + + const windowConfigKey = routeOptions.uiConfigKey || "_config"; + + const tokenHandlers = { + [CONTENT_MARKER]: context => { + return (context.user.content && context.user.content.html) || ""; + }, + + [TITLE_MARKER]: () => { + return `${routeOptions.pageTitle}`; + }, + + [APP_CONFIG_DATA_MARKER]: context => { + const { webappPrefix } = context.user.routeOptions.uiConfig; + const key = `${webappPrefix}${windowConfigKey}`; + + return ``; + }, + + [HEADER_BUNDLE_MARKER]: context => { + const manifest = bundleManifest(); + const manifestLink = manifest ? `\n` : ""; + const css = [].concat(WEBPACK_DEV ? context.user.devCSSBundle : context.user.cssChunk); + + const cssLink = css.reduce((acc, file) => { + file = WEBPACK_DEV ? file : prodBundleBase + file.name; + return `${acc}`; + }, ""); + + const htmlScripts = htmlifyScripts( + groupScripts(routeOptions.unbundledJS.enterHead).scripts, + context.user.scriptNonce + ); + + return `${manifestLink}${cssLink}${htmlScripts}`; + }, + + [BODY_BUNDLE_MARKER]: context => { + context.user.query = context.user.request.query; + const js = bundleJs(context.user); + const jsLink = js ? { src: js } : ""; + + const ins = routeOptions.unbundledJS.preBundle.concat( + jsLink, + routeOptions.unbundledJS.postBundle + ); + const htmlScripts = htmlifyScripts(groupScripts(ins).scripts, context.user.scriptNonce); + + return `${htmlScripts}`; + }, + + [DLL_BUNDLE_MARKER]: context => { + if (WEBPACK_DEV) { + return makeElectrodeDllScripts(loadElectrodeDllAssets(context.user.routeOptions)); + } + + if (context.user.routeData.dllAssetScripts === undefined) { + context.user.routeData.dllAssetScripts = makeElectrodeDllScripts( + loadElectrodeDllAssets(context.user.routeOptions), + context.user.scriptNonce + ); + } + + return context.user.routeData.dllAssetScripts; + }, + + [PREFETCH_MARKER]: prefetchBundles, + + [META_TAGS_MARKER]: iconStats, + + [CRITICAL_CSS_MARKER]: context => { + return criticalCSS ? `${criticalCSS}` : ""; + }, + + [WEBAPP_START_SCRIPT_MARKER]: context => { + const { webappPrefix } = context.user.routeOptions.uiConfig; + /* istanbul ignore next */ + const startFuncName = webappPrefix ? `${webappPrefix}WebappStart` : "webappStart"; + return ``; + }, + + INITIALIZE, + HEAD_INITIALIZE: null, + HEAD_CLOSED: null, + AFTER_SSR_CONTENT: null, + BODY_CLOSED: null, + HTML_CLOSED: null + }; + + return { + name: "electrode-react-token-handlers", + routeOptions, + routeData, + tokens: tokenHandlers + }; +}; diff --git a/packages/xarc-webapp/tsconfig.json b/packages/xarc-webapp/tsconfig.json new file mode 100644 index 000000000..f96e3e700 --- /dev/null +++ b/packages/xarc-webapp/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "outDir": "dist", + "lib": ["es2018"], + "module": "CommonJS", + "allowJs": true, + "esModuleInterop": false, + "target": "ES2018", + "preserveConstEnums": true, + "sourceMap": true, + "declaration": true, + "types": ["node", "mocha", "chai"], + "forceConsistentCasingInFileNames": true, + "noImplicitReturns": true, + "alwaysStrict": true, + "strictFunctionTypes": true, + "jsx": "react" + }, + "include": [ + "src", + "../xarc-render-context/src/symbols.ts", + "../xarc-render-context/src/TokenModule.ts", + "../xarc-render-context/src/load-handler.ts" + ] +} diff --git a/packages/xarc-webapp/xclap.ts b/packages/xarc-webapp/xclap.ts new file mode 100644 index 000000000..bb0309ccd --- /dev/null +++ b/packages/xarc-webapp/xclap.ts @@ -0,0 +1,2 @@ +import { loadTasks } from "@xarc/module-dev"; +loadTasks();