UNPKG

@dr.pogodin/react-utils

Version:

Collection of generic ReactJS components and utils

231 lines (218 loc) 8.69 kB
/** * Creation of standard ExpressJS server for ReactJS apps. */ import { sep } from 'node:path'; import { pathToFileURL } from 'node:url'; import { cloneDeep, mapValues, pick } from 'lodash-es'; import compression from 'compression'; import cookieParser from 'cookie-parser'; import csrf from '@dr.pogodin/csurf'; import express from 'express'; import favicon from 'serve-favicon'; import helmet from 'helmet'; import loggerMiddleware from 'morgan'; import requestIp from 'request-ip'; import { v4 as uuid } from 'uuid'; import rendererFactory from "./renderer"; import { CODES, ERRORS, getErrorForCode, newError } from "./utils/errors"; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions /** * Default Content Security Policy settings. * @ignore */ const defaultCspSettings = { directives: mapValues(helmet.contentSecurityPolicy.getDefaultDirectives(), // 'https:' options (automatic re-write of insecure URLs to secure ones) // is removed to facilitate local development with HTTP server. In cloud // deployments we assume Apache or Nginx server in front of out app takes // care about such re-writes. array => array.filter(item => item !== 'https:')) }; defaultCspSettings.directives['frame-src'] = ["'self'", // YouTube domain is whitelisted to allow <YouTubeVideo> component to work // out of box. 'https://*.youtube.com']; { const directives = defaultCspSettings.directives['script-src']; if (directives) directives.push("'unsafe-eval'");else defaultCspSettings.directives['script-src'] = ["'unsafe-eval'"]; } // No need for automatic re-writes via Content Security Policy settings: // the forefront Apache or Nginx server is supposed to take care of this // in production cloud deployments. delete defaultCspSettings.directives['upgrade-insecure-requests']; /** * @category Utilities * @func server/getDefaultCspSettings * @global * @desc * ```js * import { server } from '@dr.pogodin/react-utils'; * const { getDefaultCspSettings } from '@dr.pogodin/react-utils'; * ``` * @return {{ * directives: object * }} A deep copy of default CSP settings object used by `react-utils`, * with the exception of `nonce-xxx` clause in `script-src` directive, * which is added dynamically for each request. */ export function getDefaultCspSettings() { return cloneDeep(defaultCspSettings); } export default async function factory(webpackConfig, options) { const rendererOps = pick(options, ['Application', 'beforeRender', 'favicon', 'logger', 'maxSsrRounds', 'noCsp', 'ssrTimeout', 'staticCacheController', 'staticCacheSize']); const renderer = rendererFactory(webpackConfig, rendererOps); const { publicPath } = webpackConfig.output; const server = express(); if (options.beforeExpressJsSetup) { await options.beforeExpressJsSetup(server); } if (options.logger) server.logger = options.logger; if (options.httpsRedirect) { server.use((req, res, next) => { const schema = req.headers['x-forwarded-proto']; if (schema === 'http') { let url = `https://${req.headers.host}`; if (req.originalUrl !== '/') url += req.originalUrl; res.redirect(url); return; } next(); }); } server.use(compression()); server.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false, crossOriginOpenerPolicy: false, crossOriginResourcePolicy: false })); if (!options.noCsp) { server.use((req, res, next) => { const req2 = req; req2.nonce = uuid(); // TODO: This is deprecated, but it is kept for now for backward // compatibility. Should be removed sometime later. req2.cspNonce = req2.nonce; // The deep clone is necessary here to ensure that default value can't be // mutated during request processing. let cspSettings = cloneDeep(defaultCspSettings); (cspSettings.directives?.['script-src']).push(`'nonce-${req2.nonce}'`); if (options.cspSettingsHook) { cspSettings = options.cspSettingsHook(cspSettings, req); } helmet.contentSecurityPolicy(cspSettings)(req, res, next); }); } if (options.favicon) { server.use(favicon(options.favicon)); } server.use('/robots.txt', (req, res) => { res.send('User-agent: *\nDisallow:'); }); server.use(express.json({ limit: '300kb' })); server.use(express.urlencoded({ extended: false })); server.use(cookieParser(options.cookieSignatureSecret)); server.use(requestIp.mw()); server.use(csrf({ cookie: true })); loggerMiddleware.token('ip', req => req.clientIp); const FORMAT = ':ip > :status :method :url :response-time ms :res[content-length] :referrer :user-agent'; server.use(loggerMiddleware(FORMAT, { stream: { // TODO: This implies the logger is always set. Is it on a higher level? // then mark it as always present. write: options.logger.info.bind(options.logger) } })); // Note: no matter the "public path", we want the service worker, if any, // to be served from the root, to have all web app pages in its scope. // Thus, this setup to serve it. Probably, need some more configuration // for special cases, but this will do for now. server.get('/__service-worker.js', express.static(webpackConfig.output?.path ?? '', { setHeaders: res => res.set('Cache-Control', 'no-cache') })); /* Setup of Hot Module Reloading for development environment. * These dependencies are not used, nor installed for production use, * hence we should violate some import-related lint rules. */ /* eslint-disable import/no-extraneous-dependencies */ if (options.devMode) { // This is a workaround for SASS bug: // https://github.com/dart-lang/sdk/issues/27979 // which manifests itself sometimes when webpack dev middleware is used // (in dev mode), and app modules are imported in some unfortunate ways. // TODO: Double-check, what is going on here. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!global.location) { global.location = { href: `${pathToFileURL(process.cwd()).href}${sep}` }; } const { default: webpack } = await import(/* webpackChunkName: "server-side-code" */'webpack'); const { default: webpackDevMiddleware } = await import(/* webpackChunkName: "server-side-code" */'webpack-dev-middleware'); const { default: webpackHotMiddleware } = await import(/* webpackChunkName: "server-side-code" */'webpack-hot-middleware'); const compiler = webpack(webpackConfig); if (!compiler) throw Error('Internal error'); server.use(webpackDevMiddleware(compiler, { publicPath, serverSideRender: true })); server.use(webpackHotMiddleware(compiler)); } /* eslint-enable import/no-extraneous-dependencies */ server.use(publicPath, express.static(webpackConfig.output.path)); if (options.onExpressJsSetup) { await options.onExpressJsSetup(server); } server.use(renderer); /* Detects 404 errors, and forwards them to the error handler. */ server.use((req, res, next) => { next(newError(ERRORS.NOT_FOUND, CODES.NOT_FOUND)); }); let dontAttachDefaultErrorHandler; if (options.beforeExpressJsError) { dontAttachDefaultErrorHandler = await options.beforeExpressJsError(server); } /* Error handler. */ if (!dontAttachDefaultErrorHandler) { // TODO: Do we need this error handler at all? It actually seems to do // what the default ExpressJS error handler does anyway, see: // https://expressjs.com/en/guide/error-handling.html // // TODO: It is better to move the default error handler definition // to a stand-alone function at top-level, but the use of options.logger // prevents to do it without some extra refactoring. Should be done sometime // though. server.use((error, req, res, next) => { // TODO: This is needed to correctly handled any errors thrown after // sending initial response to the client. if (res.headersSent) { next(error); return; } const status = error.status ?? CODES.INTERNAL_SERVER_ERROR; const serverSide = status >= CODES.INTERNAL_SERVER_ERROR; // Log server-side errors always, client-side at debug level only. options.logger.log(serverSide ? 'error' : 'debug', error.toString()); let message = error.message || getErrorForCode(status); if (serverSide && process.env.NODE_ENV === 'production') { message = ERRORS.INTERNAL_SERVER_ERROR; } res.status(status).send(message); }); } return server; } //# sourceMappingURL=server.js.map