diff --git a/prerender-server/npm-shrinkwrap.json b/prerender-server/npm-shrinkwrap.json index 52490708..81e61c17 100644 --- a/prerender-server/npm-shrinkwrap.json +++ b/prerender-server/npm-shrinkwrap.json @@ -11,6 +11,8 @@ "dependencies": { "body-parser": "^1.19.0", "cookie-parser": "^1.4.5", + "express-queue": "^0.0.13", + "prom-client": "^14.0.0", "superagent": "^6.1.0" } }, @@ -19,6 +21,12 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", @@ -130,6 +138,55 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, + "node_modules/express-end": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/express-end/-/express-end-0.0.8.tgz", + "integrity": "sha512-PPntzICAq006LBpXKBVJtmRUiCRqTMZ+OB8L2RFXgx+OmkMWU66IL4DTEPF/DOcxmsuC7Y0NdbT2R71lb+pBpg==", + "license": "MIT", + "dependencies": { + "debug": "^2.2.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/express-queue": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/express-queue/-/express-queue-0.0.13.tgz", + "integrity": "sha512-C4OEDasGDqpXLrZICSUxbY47p5c0bKqf/3/3hwauSCmI+jVVxKBWU2w39BuKLP6nF65z87uDFBbJMPAn2ZrG3g==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "express-end": "0.0.8", + "mini-queue": "0.0.14" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/express-queue/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-queue/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/fast-safe-stringify": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz", @@ -245,6 +302,33 @@ "node": ">= 0.6" } }, + "node_modules/mini-queue": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/mini-queue/-/mini-queue-0.0.14.tgz", + "integrity": "sha512-DNh9Wn8U1jrmn1yVfpviwClyER/Y4ltgGbG+LF/KIdKJ8BEo2Q9jDDPG7tEhz6F/DTZ/ohv5D7AAXFVSFyP05Q==", + "license": "MIT", + "dependencies": { + "debug": "^3.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mini-queue/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/mini-queue/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -261,6 +345,18 @@ "node": ">= 0.8" } }, + "node_modules/prom-client": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-14.2.0.tgz", + "integrity": "sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA==", + "license": "Apache-2.0", + "dependencies": { + "tdigest": "^0.1.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/qs": { "version": "6.7.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", @@ -409,6 +505,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/toidentifier": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", diff --git a/prerender-server/package.json b/prerender-server/package.json index 92f1f310..52d50de2 100644 --- a/prerender-server/package.json +++ b/prerender-server/package.json @@ -6,8 +6,9 @@ "dependencies": { "body-parser": "^1.19.0", "cookie-parser": "^1.4.5", - "superagent": "^6.1.0", - "prom-client": "^14.0.0" + "express-queue": "^0.0.13", + "prom-client": "^14.0.0", + "superagent": "^6.1.0" }, "scripts": { "start": "./start.sh", diff --git a/prerender-server/src/cluster.js b/prerender-server/src/cluster.js index 006815e8..da3d29a5 100644 --- a/prerender-server/src/cluster.js +++ b/prerender-server/src/cluster.js @@ -5,7 +5,7 @@ if (cluster.isMaster) { console.log(`Master ${process.pid} is running`); let activeRenders = 0 - const MAX_TOTAL_RENDERS = parseInt(process.env.MAX_TOTAL_RENDERS || '1') + const MAX_TOTAL_RENDERS = parseInt(process.env.MAX_TOTAL_RENDERS || numCPUs.toString()) for (let i = 0; i < numCPUs; i++) { cluster.fork() diff --git a/prerender-server/src/server.js b/prerender-server/src/server.js index f3a8fa87..34ec7501 100644 --- a/prerender-server/src/server.js +++ b/prerender-server/src/server.js @@ -8,6 +8,10 @@ import promClient from 'prom-client' import l10n from '../client/l10n' import render from '../client/run-server' +if (!process.env.API_URL) { + throw new Error('API_URL environment variable is required but not defined.'); +} + const themes = ['light', 'dark'] , langs = Object.keys(l10n) , baseHref = process.env.BASE_HREF || '/' @@ -56,9 +60,13 @@ if (app.settings.env == 'development') app.use(require('cookie-parser')()) app.use(require('body-parser').urlencoded({ extended: false })) -app.use((req, res, next) => { - // TODO: optimize /block-height/nnn (no need to render the whole app just to get the redirect) +const queue = process.env.MAX_PENDING_RENDERS && require('express-queue')({ + activeLimit: 1, // handled by the master process, see below + queuedLimit: parseInt(process.env.MAX_PENDING_RENDERS, 10) +}); +app.use((req, res, next) => { + // Middleware to check theme and lang cookies let theme = req.query.theme || req.cookies.theme || 'dark' if (!themes.includes(theme)) theme = 'light' if (req.query.theme && req.cookies.theme !== theme) res.cookie('theme', theme) @@ -66,7 +74,16 @@ app.use((req, res, next) => { let lang = req.query.lang || req.cookies.lang || 'en' if (!langs.includes(lang)) lang = 'en' if (req.query.lang && req.cookies.lang !== lang) res.cookie('lang', lang) + req.renderOpts = { theme, lang } + next() +}) + +if (queue) app.use(queue) + +app.use((req, res, next) => { + // TODO: optimize /block-height/nnn (no need to render the whole app just to get the redirect) + // IPC-based queuing for cluster mode if (typeof process.send === 'function') { const requestId = ++requestCounter process.send({ type: 'startRender', requestId }) @@ -77,8 +94,9 @@ app.use((req, res, next) => { clearTimeout(timeout) process.removeListener('message', handler) if (msg.type === 'renderAllowed') { - doRender() + doRender(req, res, next) } else if (msg.type === 'renderDenied') { + // received when the master's render queue is full res.status(503).send('Server overloaded') } } @@ -93,40 +111,45 @@ app.use((req, res, next) => { } }, 5000) // 5 second timeout } else { - doRender() + // standalone mode + doRender(req, res, next) } +}) - function doRender() { - activeRenders.inc() - const end = renderDuration.startTimer() - let metricsUpdated = false - render(req._parsedUrl.pathname, req._parsedUrl.query || '', req.body, { theme, lang, isHead: req.method === 'HEAD' }, (err, resp) => { - if (!metricsUpdated) { - metricsUpdated = true - if (typeof process.send === 'function') process.send({ type: 'endRender' }) - activeRenders.dec() - end() - totalRenders.inc() - } - if (err) return next(err) - if (resp.redirect) return res.redirect(301, baseHref + resp.redirect) - if (resp.errorCode) { - console.error(`Failed with code ${resp.errorCode}:`, resp) - return res.sendStatus(resp.errorCode) - } +function doRender(req, res, next) { + activeRenders.inc() + const end = renderDuration.startTimer() + let metricsUpdated = false + render(req._parsedUrl.pathname, req._parsedUrl.query || '', req.body, { ...req.renderOpts, isHead: req.method === 'HEAD' }, (err, resp) => { + if (!metricsUpdated) { + metricsUpdated = true + // inform the master process that we're done rendering and can accept new requests + if (typeof process.send === 'function') process.send({ type: 'endRender' }) + // and tell express-queue that we're ready for the next one + if (queue) queue.next() + activeRenders.dec() + end() + totalRenders.inc() + } - res.status(resp.status || 200) - res.render(indexView, { - prerender_title: resp.title - , prerender_html: resp.html - , canon_url: canonBase ? canonBase + req.url : null - , noscript: true - , theme - , t: l10n[lang] - }) + if (err) return next(err) + if (resp.redirect) return res.redirect(301, baseHref + resp.redirect) + if (resp.errorCode) { + console.error(`Failed with code ${resp.errorCode}:`, resp) + return res.sendStatus(resp.errorCode) + } + + res.status(resp.status || 200) + res.render(indexView, { + prerender_title: resp.title + , prerender_html: resp.html + , canon_url: canonBase ? canonBase + req.url : null + , noscript: true + , ...req.renderOpts + , t: l10n[req.renderOpts.lang] }) - } -}) + }) +} // Cleanup socket file from previous executions if (process.env.SOCKET_PATH) {