UNPKG

@dr.pogodin/react-utils

Version:

Collection of generic ReactJS components and utils

516 lines (492 loc) 19.2 kB
/** * ExpressJS middleware for server-side rendering of a ReactJS app. */ import fs from 'node:fs'; import path from 'node:path'; import { Writable } from 'node:stream'; import { brotliCompress, brotliDecompress } from 'node:zlib'; import winston from 'winston'; import { GlobalStateProvider, SsrContext } from '@dr.pogodin/react-global-state'; import { timer } from '@dr.pogodin/js-utils'; import { cloneDeep, defaults, get, mapValues } from 'lodash-es'; import config from 'config'; import forge from 'node-forge'; import { prerenderToNodeStream } from 'react-dom/static'; import { HelmetProvider } from '@dr.pogodin/react-helmet'; import { StaticRouter } from 'react-router'; import serializeJs from 'serialize-javascript'; import { setBuildInfo } from "../shared/utils/isomorphy/buildInfo.js"; import Cache from "./Cache.js"; // @ts-expect-error "Property 'SECRET' does not exist on type 'IConfig'." // eslint-disable-next-line @typescript-eslint/no-unused-vars import { jsx as _jsx } from "react/jsx-runtime"; const { SECRET, ...sanitizedConfig } = config; // Note: These type definitions for logger are copied from Winston logger, // then simplified to make it easier to fit an alternative logger into this // interface. // eslint-disable-next-line @typescript-eslint/consistent-type-definitions // eslint-disable-next-line @typescript-eslint/consistent-type-definitions // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export let SCRIPT_LOCATIONS = /*#__PURE__*/function (SCRIPT_LOCATIONS) { SCRIPT_LOCATIONS["BODY_OPEN"] = "BODY_OPEN"; SCRIPT_LOCATIONS["DEFAULT"] = "DEFAULT"; SCRIPT_LOCATIONS["HEAD_OPEN"] = "HEAD_OPEN"; return SCRIPT_LOCATIONS; }({}); export class ServerSsrContext extends SsrContext { chunks = []; status = 200; constructor(req, chunkGroups, initialState) { super(cloneDeep(initialState) ?? {}); this.chunkGroups = chunkGroups; this.req = req; } } /** * Reads build-time information about the app. This information is generated * by our standard Webpack config for apps, and it is written into * ".build-info" file in the context folder specified in Webpack config. * At the moment, that file contains build timestamp and a random 32-bit key, * suitable for cryptographical use. * @param context Webpack context path used during the build. * @return Resolves to the build-time information. */ function getBuildInfo(context) { const url = path.resolve(context, '.build-info'); return JSON.parse(fs.readFileSync(url, 'utf8')); } /** * Attempts to read from disk the named chunk groups mapping generated * by Webpack during the compilation. * It will not work for development builds, where these stats should be captured * via compilator callback. * @param buildDir * @return */ function readChunkGroupsJson(buildDir) { const url = path.resolve(buildDir, '__chunk_groups__.json'); let res; try { res = JSON.parse(fs.readFileSync(url, 'utf8')); } catch { // TODO: Should we message the error here somehow? res = null; } return res; } /** * Prepares a new Cipher for data encryption. * @param key Encryption key (32-bit random key is expected, see * node-forge documentation, in case of doubts). * @return Resolves to the object with two fields: * 1. cipher - a new Cipher, ready for encryption; * 2. iv - initial vector used by the cipher. */ async function prepareCipher(key) { return new Promise((resolve, reject) => { forge.random.getBytes(32, (err, iv) => { if (err) reject(err);else { const cipher = forge.cipher.createCipher('AES-CBC', key); cipher.start({ iv }); resolve({ cipher, iv }); } }); }); } /** * Given an incoming HTTP requests, it deduces whether Brotli-encoded responses * are acceptable to the caller. * @param req */ export function isBrotliAcceptable(req) { const acceptable = req.get('accept-encoding'); if (acceptable) { const ops = acceptable.split(','); for (const op of ops) { const [type, priority] = op.trim().split(';q='); if ((type === '*' || type === 'br') && (!priority || parseFloat(priority) > 0)) { return true; } } } return false; } /** * Given an array of extra script strings / objects, it returns an object with * arrays of scripts to inject in different HTML template locations. During * the script groupping it also filters out any empty scripts. * @param {({ * code: string; * location: string; * }|string)[]} [scripts=[]] * @return {{ * BODY_OPEN: string[]; * DEFAULT: string[]; * HEAD_OPEN: string[]; * }} */ function groupExtraScripts(scripts = []) { const res = { [SCRIPT_LOCATIONS.BODY_OPEN]: '', [SCRIPT_LOCATIONS.DEFAULT]: '', [SCRIPT_LOCATIONS.HEAD_OPEN]: '' }; for (const script of scripts) { if (typeof script === 'string') { if (script) res[SCRIPT_LOCATIONS.DEFAULT] += script; } else if (script.code) { if (script.location in res) res[script.location] += script.code;else throw Error(`Invalid location "${script.location}"`); } } return res; } /** * Creates a new default (Winston) logger. * @param {object} [options={}] * @param {string} [options.defaultLogLevel='info'] * @return {object} */ export function newDefaultLogger({ defaultLogLevel = 'info' } = {}) { const { format, transports } = winston; return winston.createLogger({ format: format.combine(format.splat(), format.timestamp(), format.colorize(), format.printf(({ level, message, timestamp, stack, ...rest }) => { let res = `${level}\t(at ${timestamp}):\t${message}`; if (Object.keys(rest).length) { res += `\n${JSON.stringify(rest, null, 2)}`; } if (stack) res += `\n${stack}`; return res; })), level: defaultLogLevel, transports: [new transports.Console()] }); } /** * Creates the middleware. * @param webpackConfig * @param options Additional options: * @param [options.Application] The root ReactJS component of * the app to use for the server-side rendering. When not provided * the server-side rendering is disabled. * @param [options.buildInfo] "Build info" object to use. If provided, * it will be used, instead of trying to load from the filesystem the one * generated by the Webpack build. It is intended for test environments, * where passing this stuff via file system is no bueno. * @param [options.favicon] `true` will include favicon * link into the rendered HTML templates. * @param [options.noCsp] `true` means that no * Content-Security-Policy (CSP) is used by server, thus the renderer * may cut a few corners. * @param [options.maxSsrRounds=10] Maximum number of SSR rounds. * @param [options.ssrTimeout=1000] SSR timeout in milliseconds, * defaults to 1 second. * @param [options.staticCacheSize=1.e7] The maximum * static cache size in bytes. Defaults to ~10 MB. * @param [options.staticCacheController] When given, it activates, * and controls the static caching of generated HTML markup. When this function * is provided, on each incoming request it is triggered with the request * passed in as the argument. To attempt to serve the response from the cache * it should return the object with the following fields: * - `key: string` &ndash; the cache key for the response; * - `maxage?: number` &ndash; the maximum age of cached result in ms. * If undefined - infinite age is assumed. * @return Created middleware. */ export default function factory(webpackConfig, options) { const ops = defaults({ ...options }, { beforeRender: async () => Promise.resolve({}), maxSsrRounds: 10, ssrTimeout: 1000, staticCacheSize: 1.e7 }); // Note: in normal use the default logger is created and set in the root // server function, and this initialization is for testing uses, where // renderer is imported directly. ops.logger ??= newDefaultLogger({ defaultLogLevel: ops.defaultLoggerLogLevel }); const buildInfo = ops.buildInfo ?? getBuildInfo(webpackConfig.context); setBuildInfo(buildInfo); // publicPath from webpack.output has a trailing slash at the end. const { publicPath, path: outputPath } = webpackConfig.output; const manifestLink = fs.existsSync(`${outputPath}/manifest.json`) ? `<link rel="manifest" href="${publicPath}manifest.json">` : ''; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions const cache = ops.staticCacheController ? new Cache(ops.staticCacheSize) : null; const CHUNK_GROUPS = readChunkGroupsJson(outputPath); // TODO: Look at it later. // eslint-disable-next-line complexity return async (req, res, next) => { try { // Ensures any caches always revalidate HTML markup before reuse. res.set('Cache-Control', 'no-cache'); res.cookie('csrfToken', req.csrfToken()); let cacheRef; if (cache) { cacheRef = ops.staticCacheController(req); if (cacheRef) { const data = cache.get(cacheRef); if (data !== null) { const { buffer, status } = data; if (ops.noCsp && isBrotliAcceptable(req)) { res.set('Content-Type', 'text/html'); res.set('Content-Encoding', 'br'); if (status !== 200) res.status(status); res.send(buffer); } else { await new Promise((done, failed) => { brotliDecompress(buffer, (error, html) => { if (error) failed(error);else { let h = html.toString(); if (!ops.noCsp) { // TODO: Starting from Node v15 we'll be able to use string's // .replaceAll() method instead relying on reg. expression for // global matching. const regex = new RegExp(buffer.nonce, 'g'); // TODO: It should be implemented more careful. h = h.replace(regex, req.nonce); } if (status !== 200) res.status(status); res.send(h); done(); } }); }); } return; } } } const brr = ops.beforeRender(req, sanitizedConfig); const [{ configToInject, extraScripts, initialState }, { cipher, iv }] = await Promise.all([ // NOTE: Written this way to avoid triggering the "await-thenable" // ESLint rule. brr instanceof Promise ? brr : Promise.resolve(brr), prepareCipher(buildInfo.key)]); let helmet; // Gets the mapping between code chunk names and their asset files. // These data come from the Webpack compilation, either from the stats // attached to the request (in dev mode), or from a file output during // the build (in prod mode). let chunkGroups; const webpackStats = get(res.locals, 'webpack.devMiddleware.stats'); if (webpackStats) { chunkGroups = mapValues(webpackStats.toJson({ all: false, chunkGroups: true }).namedChunkGroups, item => item.assets?.map(({ name }) => name) ?? []); } else if (CHUNK_GROUPS) chunkGroups = CHUNK_GROUPS;else chunkGroups = {}; /* Optional server-side rendering. */ const App = ops.Application; let appHtmlMarkup = ''; const ssrContext = new ServerSsrContext(req, chunkGroups, initialState); let stream; if (App) { const ssrStart = Date.now(); // TODO: Somehow, without it TS does not realise that // App has been checked to exist. const App2 = App; const renderPass = async () => new Promise((resolveArg, rejectArg) => { ssrContext.chunks = []; // NOTE: JS does not have problems if resolve() and reject() methods // of a Promise are called multiple times, with different arguments; // it only respects the first call, and ignores subsequent ones. // We, however, really want to assert that here, to safeguard against // any future problems due to unexpected internal changes in React, // if any. let error; const resolve = arg => { if (error !== undefined) throw Error('Internal error'); error = null; resolveArg(arg); }; const reject = arg => { if (error !== undefined && error !== arg) { throw Error('Internal error'); } error = arg; rejectArg(arg); }; // TODO: prerenderToNodeStream has (abort) "signal" option, // and we should wire it up to the SSR timeout below. const helmetContext = {}; void prerenderToNodeStream(/*#__PURE__*/_jsx(GlobalStateProvider, { initialState: ssrContext.state, ssrContext: ssrContext, children: /*#__PURE__*/_jsx(StaticRouter, { location: req.url, children: /*#__PURE__*/_jsx(HelmetProvider, { context: helmetContext, children: /*#__PURE__*/_jsx(App2, {}) }) }) }), { onError: reject }).then(result => { ({ helmet } = helmetContext); resolve(result.prelude); }).catch(reject); }); let ssrRound = 0; let bailed = false; for (; ssrRound < ops.maxSsrRounds; ++ssrRound) { stream = await renderPass(); if (!ssrContext.dirty) break; const timeout = ops.ssrTimeout + ssrStart - Date.now(); bailed = timeout <= 0 || !(await Promise.race([Promise.allSettled(ssrContext.pending), timer(timeout).then(() => false)])); if (bailed) break; } let logMsg; if (ssrContext.dirty) { // NOTE: In the case of incomplete SSR one more round is necessary // to ensure the correct hydration when some pending promises have // resolved and placed their data into the initial global state. stream = await renderPass(); logMsg = bailed ? `SSR timed out after ${ops.ssrTimeout} second(s)` : `SSR bailed out after ${ops.maxSsrRounds} round(s)`; } else logMsg = `SSR completed in ${ssrRound + 1} round(s)`; ops.logger.log(ssrContext.dirty ? 'warn' : 'info', logMsg); if (ssrContext.redirectTo) { res.redirect(ssrContext.status, ssrContext.redirectTo); return; } await new Promise(ready => { stream.pipe(new Writable({ destroy: ready, write: (chunk, _, done) => { appHtmlMarkup += chunk.toString(); done(); } })); }); } /* Encrypts data to be injected into HTML. * Keep in mind, that this encryption is no way secure: as the JS bundle * contains decryption key and is able to decode it at the client side. * Hovewer, for a number of reasons, encryption of injected data is still * better than injection of a plain text. */ const payload = serializeJs({ CHUNK_GROUPS: chunkGroups, CONFIG: configToInject ?? sanitizedConfig, ISTATE: ssrContext.state }, { ignoreFunction: true, unsafe: true }); cipher.update(forge.util.createBuffer(payload, 'utf8')); cipher.finish(); const INJ = forge.util.encode64(`${iv}${cipher.output.data}`); const chunkSet = new Set(); // TODO: "main" chunk has to be added explicitly, // because unlike all other chunks they are not managed by <CodeSplit> // component, thus they are not added to the ssrContext.chunks // automatically. Actually, names of these entry chunks should be // read from Wepback config, as the end user may customize them, // remove or add other entry points, but it requires additional // efforts to figure out how to automatically order them right, // thus for now this handles the default config. ['main', ...ssrContext.chunks].forEach(chunk => { const assets = chunkGroups[chunk]; if (assets) assets.forEach(asset => chunkSet.add(asset)); }); let styleChunkString = ''; let scriptChunkString = ''; chunkSet.forEach(chunk => { if (chunk.endsWith('.css')) { styleChunkString += `<link href="${publicPath}${chunk}" rel="stylesheet">`; } else if (chunk.endsWith('.js') // In dev mode HMR adds JS updates into asset arrays, // and they (updates) should be ignored. && !chunk.endsWith('.hot-update.js')) { scriptChunkString += `<script src="${publicPath}${chunk}" type="application/javascript"></script>`; } }); const grouppedExtraScripts = groupExtraScripts(extraScripts); const faviconLink = ops.favicon ? '<link rel="shortcut icon" href="/favicon.ico">' : ''; const html = `<!DOCTYPE html> <html lang="en"> <head> ${grouppedExtraScripts[SCRIPT_LOCATIONS.HEAD_OPEN]} ${helmet?.title.toString() ?? ''} ${helmet?.meta.toString() ?? ''} <meta name="theme-color" content="#FFFFFF"> ${manifestLink} ${helmet?.link.toString() ?? ''}${styleChunkString} ${faviconLink} <meta charset="utf-8"> <meta content="width=device-width,initial-scale=1.0" name="viewport" > <meta itemprop="drpruinj" content="${INJ}"> </head> <body> ${grouppedExtraScripts[SCRIPT_LOCATIONS.BODY_OPEN]} <div id="react-view">${appHtmlMarkup}</div> ${scriptChunkString} ${grouppedExtraScripts[SCRIPT_LOCATIONS.DEFAULT]} </body> </html>`; const status = ssrContext.status || 200; if (status !== 200) res.status(status); if (cacheRef && status < 500) { // Note: waiting for the caching to complete is not strictly necessary, // but it greately simplifies testing, and error reporting. await new Promise((done, failed) => { brotliCompress(html, (error, buffer) => { if (error) failed(error);else { const b = buffer; b.nonce = req.nonce; cache.add({ buffer: b, status }, cacheRef.key, buffer.length); done(); } }); }); } // Note: as caching code above may throw in some cases, sending response // before it completes will likely hide the error, making it difficult // to debug. Thus, at least for now, lets send response after it. res.send(html); } catch (error) { next(error); } }; } //# sourceMappingURL=renderer.js.map