UNPKG

@boomerang-io/webapp-spa-server

Version:

Webapp Server for React-based SPA w/ client-side routing

311 lines (278 loc) 10.3 kB
"use strict"; /*eslint-env node*/ const path = require("path"); const fs = require("fs"); const cors = require("cors"); const serialize = require("serialize-javascript"); const boomerangLogger = require("@boomerang-io/logger-middleware")("webapp-spa-server/index.js"); const health = require("@cloudnative/health-connect"); const defaultHtmlHeadInjectDataKeys = require("./config").defaultHtmlHeadInjectDataKeys; // Get logger function const logger = boomerangLogger.logger; /** * Begin exported module */ function createBoomerangServer({ corsConfig, disableInjectHTMLHeadData, }) { /** * Read in values from process.env object * Set defaults for the platform for unprovided values */ const { APP_ROOT = "/", PORT = 3000, HTML_HEAD_INJECTED_DATA_KEYS = defaultHtmlHeadInjectDataKeys.join(), NEW_RELIC_APP_NAME, NEW_RELIC_LICENSE_KEY, HTML_HEAD_INJECTED_SCRIPTS, BUILD_DIR = "build", CORS_CONFIG, } = process.env; const appCorsConfig = corsConfig || parseJSONString(CORS_CONFIG); // Monitoring if (NEW_RELIC_APP_NAME && NEW_RELIC_LICENSE_KEY) { require("newrelic"); } /** * Start Express app */ const express = require("express"); const app = express(); // Compression const compression = require("compression"); app.use(compression()); // Logging app.use(boomerangLogger.middleware); // Security const helmet = require("helmet"); app.use( helmet({ referrerPolicy: { policy: "strict-origin-when-cross-origin" }, contentSecurityPolicy: false, crossOriginEmbedderPolicy: false, crossOriginOpenerPolicy: false, crossOriginResourcePolicy: false, }) ); app.disable("x-powered-by"); appCorsConfig && app.use(cors(appCorsConfig)); // Parsing const bodyParser = require("body-parser"); app.use(bodyParser.urlencoded({ extended: true })); // Initialize healthchecker and add routes const healthchecker = new health.HealthChecker(); app.use("/health", health.LivenessEndpoint(healthchecker)); app.use("/ready", health.ReadinessEndpoint(healthchecker)); // Create endpoint for the app serve static assets const appRouter = express.Router(); /** * Next two routes are needed for serving apps with client-side routing * Do NOT return index.html file by default if `disableInjectHTMLHeadData = true`. We need append data to it. * It will be returned on the second route * https://github.com/facebook/create-react-app/blob/master/packages/react-scripts/template/README.md#serving-apps-with-client-side-routing * Cache assets "forever" aka max recommended value of one year */ if (!disableInjectHTMLHeadData) { appRouter.use( "/", express.static(path.join(process.cwd(), BUILD_DIR), { maxAge: 31536000000, index: false, }) ); appRouter.get("/*", (_, res) => injectEnvDataAndScriptsIntoHTML({ res, appRoot: APP_ROOT, buildDir: BUILD_DIR, injectedDataKeys: HTML_HEAD_INJECTED_DATA_KEYS, injectedScripts: HTML_HEAD_INJECTED_SCRIPTS, }) ); } else { appRouter.use("/", express.static(path.join(process.cwd(), BUILD_DIR))); } // Make sure that files with .css has a type="text/css" since they were throwing errors related to MIME check app.use(express.static(APP_ROOT, { setHeaders: function (res, path) { if (path.endsWith(".css")) { res.set("Content-Type", "text/css"); } } })); app.use(APP_ROOT, appRouter); // Start server on the specified port and binding host app.listen(PORT, "0.0.0.0", function () { logger.debug("server starting on", PORT); logger.debug(`serving on root context: ${APP_ROOT}`); logger.info(`View app: http://localhost:${PORT}${APP_ROOT}`); }); // Return server if needed to be used in an app return app; } /** * Start utility functions */ /** * Add JSON data and scripts to the html file based on environment. Enables same docker image to be used in any environment * https://medium.com/@housecor/12-rules-for-professional-javascript-in-2015-f158e7d3f0fc * https://stackoverflow.com/questions/33027089/res-sendfile-in-node-express-with-passing-data-along * @param {function} res - Express response function * @param {string} buildDir - build directory for building up path to index.html file * @param {string} injectedDataKeys - string of comma delimited values * @param {string} injectedScripts - string of comma delimited values */ function injectEnvDataAndScriptsIntoHTML({ res, buildDir, appRoot, injectedDataKeys, injectedScripts }) { // Build up object of external data to append const headInjectedData = injectedDataKeys.split(",").reduce((acc, key) => { acc[key] = process.env[key]; return acc; }, {}); // Build up string of scripts to append, absolute path const localScriptTags = injectedScripts ? injectedScripts .split(",") .reduce((acc, currentValue) => `${acc}<script async src="${appRoot}/${currentValue}"></script>`, "") : ""; // Set the response type so browser interprets it as an html file res.type(".html"); // Read in HTML file and add callback functions for EventEmitter events produced by ReadStream fs.createReadStream(path.join(process.cwd(), buildDir, "index.html")) .on("end", () => { res.end(); }) .on("error", (e) => logger.error(e)) .on("data", (chunk) => res.write(addHeadData(chunk))); /** * Convert buffer to string and replace closing head tag with env-specific data and additional scripts * Serialize data for security * https://medium.com/node-security/the-most-common-xss-vulnerability-in-react-js-applications-2bdffbcc1fa0 * @param {Buffer} chunk * @return {string} replaced string with data interopolated */ function addHeadData(chunk) { return chunk.toString().replace( "</head>", `<script> window._SERVER_DATA = ${serialize(headInjectedData, { isJSON: true, })}; </script> ${getBeeheardSurveyScripts()} ${getGAScripts()} ${getBluemixSegmentScripts()} ${getInstanaScripts()} ${localScriptTags} </head>` ); } } // Include BeeHeard survey based on env var function getBeeheardSurveyScripts() { const enableBeeheardSurvey = process.env.ENABLE_BEEHEARD_SURVEY; return Boolean(enableBeeheardSurvey) ? '<script async src="https://beeheard.dal1a.cirrus.ibm.com/survey/preconfig/HHPxpQgN.js" crossorigin></script>' : ""; } // Include Google Analytics based on env var function getBluemixSegmentScripts() { const enableSegment = process.env.SEGMENT_ENABLED; const segmentUrl = process.env.SEGMENT_SCRIPT_URL; const segmentKey = process.env.SEGMENT_KEY; return Boolean(enableSegment) ? `<script> digitalData = { page: { pageInfo: { pageID: "ibm_consulting_advantage_test", productCode: "694970X", productCodeType: "Consulting Assistants", productTitle: "IBM Consulting Advantage", analytics: { category: "Offering Interface" } } } }; </script> <script type="text/javascript"> window._analytics = { "segment_key" : "${segmentKey}", "coremetrics" : false, "optimizely" : false, "googleAddServices": false, "fullStory" : false}; </script> <script src="${segmentUrl}" crossorigin></script> <script> window._analytics = { "pageProperties": { "platformTitle": "IBM Consulting Advantage", "productCode": "694970X", "productCodeType": "IBM Consulting Advantage", }, "commonProperties": { "platformTitle": "IBM Consulting Advantage", "productCode": "694970X", "productCodeType": "IBM Consulting Advantage", } }; </script> ` : ""; } // Include Google Analytics based on env var function getGAScripts() { const gaSiteId = process.env.GA_SITE_ID; const gaUrl = process.env.GA_SCRIPT_URL; const gaEnabled = process.env.GA_ENABLED; return Boolean(gaEnabled) ? `<script type="text/javascript"> window.idaPageIsSPA = true; window._ibmAnalytics = { settings: { name: "IBM_Services_Essentials", isSpa: true, tealiumProfileName: "ibm-web-app", }, trustarc: { isCookiePreferencesButtonAlwaysOn: false, }, }; digitalData = { page: { pageInfo: { ibm: { siteID: '${gaSiteId}', } }, category: { primaryCategory: 'PC100' } } }; </script> <script async src="${gaUrl}" type="text/javascript" crossorigin></script>` : ""; } // Include Instana monitoring based on env var function getInstanaScripts() { const instanaReportingUrl = process.env.INSTANA_REPORTING_URL; const instanaKey = process.env.INSTANA_KEY; return Boolean(instanaReportingUrl) && Boolean(instanaKey) ? `<script type="text/javascript"> (function(s,t,a,n){s[t]||(s[t]=a,n=s[a]=function(){n.q.push(arguments)}, n.q=[],n.v=2,n.l=1*new Date)})(window,"InstanaEumObject","ineum"); ineum('reportingUrl', '${instanaReportingUrl}'); ineum('key', '${instanaKey}'); ineum('trackSessions'); </script> <script async crossorigin="anonymous" src="https://eum.instana.io/eum.min.js"></script>` : ""; } //Check if a CORS_CONFIG from env is a valid JSON object and return it if so function parseJSONString(jsonString) { try { const parseJSON = Boolean(jsonString) && JSON.parse(jsonString); return typeof parseJSON === "object" ? parseJSON : false; } catch (e) { console.log(`JSON Parse error: ${e}`); return false; } }; module.exports = createBoomerangServer;