UNPKG

subapp-server

Version:

Electrode SubApp app server support

275 lines (226 loc) 8.37 kB
"use strict"; /* eslint-disable no-magic-numbers, max-statements */ const Path = require("path"); const assert = require("assert"); const _ = require("lodash"); const xaa = require("xaa"); const HttpStatus = require("./http-status"); const subAppUtil = require("subapp-util"); const HttpStatusCodes = require("http-status-codes"); const Fs = require("fs"); const util = require("util"); const readFile = util.promisify(Fs.readFile); const optionalRequire = require("optional-require")(require); const getDevProxy = optionalRequire("@xarc/app-dev/config/get-dev-proxy"); const { utils: { resolveChunkSelector } } = require("@xarc/index-page"); const { getSrcDir, makeErrorStackResponse, checkSSRMetricsReporting, updateFullTemplate, setCSPNonce, getCSPHeader, until } = require("./utils"); const routesFromFile = require("./routes-from-file"); const routesFromDir = require("./routes-from-dir"); const templateRouting = require("./template-routing"); function makeRouteHandler({ path, routeRenderer, routeOptions }) { const useStream = routeOptions.useStream !== false; const { settings = {} } = getDevProxy ? getDevProxy() : {}; const { webpackDev } = settings; return async (request, reply) => { try { const { styleNonce = "", scriptNonce = "" } = setCSPNonce({ routeOptions }); // wait for webpack stats to be valid if webpackDev if (webpackDev) { await until(() => request.app.webpackDev.valid === true, 400); console.log(`Webpack stats valid: ${request.app.webpackDev.valid}`); } const context = await routeRenderer({ useStream, mode: "", request }); const data = context.result; const status = data.status; let cspHeader; /** If csp headers are provided by application in route options then use that otherwise generate CSP headers */ if (routeOptions.cspHeaderValues instanceof Function) { const rawCSPHeader = routeOptions.cspHeaderValues({ styleNonce, scriptNonce }); // Replace newline characters and spaces cspHeader = rawCSPHeader.replace(/\s{2,}/g, " ").trim(); } else { cspHeader = getCSPHeader({ styleNonce, scriptNonce }); } if (cspHeader) { reply.header("Content-Security-Policy", cspHeader); } if (data instanceof Error) { // rethrow to get default error behavior below with helpful errors in dev mode throw data; } else if (status === undefined) { reply.type("text/html; charset=UTF-8").code(HttpStatusCodes.OK); return reply.send(data); } else if (HttpStatus.redirect[status]) { return reply.redirect(status, data.path); } else if (HttpStatus.displayHtml[status] || (status >= HttpStatusCodes.OK && status < 300)) { reply.type("text/html; charset=UTF-8").code(status); return reply.send(data.html !== undefined ? data.html : data); } else { reply.code(status); return reply.send(data); } } catch (err) { reply.status(HttpStatusCodes.INTERNAL_SERVER_ERROR); if (process.env.NODE_ENV !== "production") { const responseHtml = makeErrorStackResponse(path, err); reply.type("text/html; charset=UTF-8"); return reply.send(responseHtml); } else { return reply.send("Internal Server Error"); } } }; } function getRoutePaths(route, path = null) { const defaultMethods = [].concat(route.methods || "get"); const paths = _.uniq([path].concat(route.path, route.paths).filter(x => x)).map(x => { if (typeof x === "string") { return { [x]: defaultMethods }; } return x; }); return paths; } async function registerFastifyRoutesFromFile({ fastify, srcDir, routes, topOpts }) { checkSSRMetricsReporting(topOpts); const subApps = await subAppUtil.scanSubAppsFromDir(srcDir); const subAppsByPath = subAppUtil.getSubAppByPathMap(subApps); for (const path in routes) { const route = routes[path]; const routeOptions = Object.assign({}, topOpts, route, { path }); routeOptions.uiConfig = _.get(fastify, "settings.app.config.ui", {}); const routeRenderer = routesFromFile.setupRouteTemplate({ server: fastify, subAppsByPath, srcDir, routeOptions }); const handler = makeRouteHandler({ path, routeRenderer, routeOptions }); if (routeOptions.auth) { const authStrategies = [].concat(routeOptions.auth).map(auth => fastify[auth]); const userPreHandler = _.get(route, "settings.preHandler"); if (userPreHandler) { route.settings.preHandler = fastify.auth([...authStrategies, route.settings.preHandler], { run: "all" }); } else { _.set(route, "settings.preHandler", fastify.auth(authStrategies)); } } getRoutePaths(route, path).forEach(pathObj => { _.each(pathObj, (method, xpath) => { fastify.route({ ...route.settings, path: xpath, method: method.map(x => x.toUpperCase()), handler }); }); }); } } async function registerFastifyRoutesFromDir({ fastify, topOpts, routes }) { checkSSRMetricsReporting(topOpts); routes.forEach(routeInfo => { const { route } = routeInfo; const routeOptions = Object.assign( {}, topOpts, _.pick(route, ["pageTitle", "bundleChunkSelector", "templateFile", "selectTemplate"]) ); routeOptions.uiConfig = _.get(fastify, "settings.app.config.ui", {}); assert( routeOptions.templateFile, `subapp-server: route ${routeInfo.name} must define templateFile` ); updateFullTemplate(routeInfo.dir, routeOptions); const chunkSelector = resolveChunkSelector(routeOptions); routeOptions.__internals = { chunkSelector }; const routeRenderer = templateRouting.makeRouteTemplateSelector(routeOptions); const paths = getRoutePaths(route); for (const pathObj of paths) { _.each(pathObj, (method, xpath) => { const routeHandler = makeRouteHandler({ path: xpath, routeRenderer, routeOptions }); fastify.route({ ...route.options, path: xpath, method: method.map(x => x.toUpperCase()), handler: routeHandler }); }); } }); } async function setupRoutesFromDir(fastify, srcDir, fromDir) { const { routes, topOpts } = fromDir; topOpts.routes = _.merge({}, routes, topOpts.routes); const routesWithSetup = routes.filter(x => x.route.setup); for (const route of routesWithSetup) { await route.route.setup(fastify); } // TODO: invoke optional route intiailize hook // in case needed, add full protocol/host/port to dev bundle base URL topOpts.devBundleBase = subAppUtil.formUrl({ ..._.pick(topOpts.devServer, ["protocol", "host", "port"]), path: topOpts.devBundleBase }); await registerFastifyRoutesFromDir({ fastify, srcDir, topOpts, routes }); } async function handleFavIcon(fastify, options) { // // favicon handling, turn off by setting options.favicon to false // if (options.favicon === false) { return; } // look in CWD/static let icon; const favIcons = [options.favicon, "static/favicon.ico", "static/favicon.png"].filter(_.identity); for (let i = 0; i < favIcons.length && !icon; i++) { const file = Path.resolve(favIcons[i]); icon = await xaa.try(() => readFile(file)); } fastify.route({ method: "GET", path: "/favicon.ico", handler(request, reply) { if (icon) { reply.type("image/x-icon").send(icon).status(HttpStatusCodes.OK); } else { reply.send("").status(HttpStatusCodes.NOT_FOUND); } } }); } module.exports = { fastifyPlugin: async (fastify, pluginOpts) => { const srcDir = getSrcDir(pluginOpts); await handleFavIcon(fastify, pluginOpts); const fromDir = await routesFromDir.searchRoutes(srcDir, pluginOpts); if (fromDir) { return await setupRoutesFromDir(fastify, srcDir, fromDir); } const { routes, topOpts } = routesFromFile.searchRoutes(srcDir, pluginOpts); // invoke setup callback for (const path in routes) { if (routes[path].setup) { await routes[path].setup(fastify); } } return registerFastifyRoutesFromFile({ fastify, srcDir, routes, topOpts }); } };