@phbalance/prerender-ssr
Version:
Prerender/server side render using headless chrome.
180 lines (149 loc) • 6.02 kB
JavaScript
// NOTE: Uses await etc so requires at least node 7.6
const url = require("url");
const zlib = require("zlib");
const fs = require("fs");
const cmdline = require("./command_line");
const express = require("express");
const accept = require("@hapi/accept");
const https = require("https");
const { createProxyMiddleware } = require("http-proxy-middleware");
const compression = require("compression");
const { ssr, startBrowser } = require("./ssr");
const log = require("loglevel");
// Start the server.
const app = express();
// Enable on the fly compression?
if(cmdline.compression) {
log.info("on the fly compression enabled");
app.use(compression({ level: zlib.constants.Z_BEST_COMPRESSION }));
}
// Do we know for sure that we're behind a proxy?
if(cmdline.proxy) {
log.info("behind a proxy: ", cmdline.proxy);
// From docs:
// Enabling trust proxy will have the following impact:
// The value of req.hostname is derived from the value set in the X-Forwarded-Host header, which can be set by the client or by the proxy.
// X-Forwarded-Proto can be set by the reverse proxy to tell the app whether it is https or http or even an invalid name. This value is reflected by req.protocol.
// The req.ip and req.ips values are populated with the list of addresses from X-Forwarded-For.
app.enable("trust proxy", cmdline.proxy);
}
// Serving static files?
if(cmdline.public) {
log.info("starting static server");
app.use(express.static(cmdline.public, { index: false }));
}
const proxyMap = new Map(cmdline.map);
const preRenderUrl = async (req, res, next) => {
// Does this URL look like something we render?
if(!req.originalUrl.match(/.(js|js.map|css|jpg|jpeg|png|ico|txt|xml)$/)) {
const protHostPort = `${req.protocol}://${req.get('Host')}`;
const pretendToBeUrl = `${protHostPort}${req.originalUrl}`;
const fetchFromUrl = `${proxyMap.get(protHostPort)}${req.originalUrl}`;
// Choose the appropriate response encoding. We can support brotli, gzip, and
// the identity transform. Give priority to brotli then gzip and finally nothing.
const encoding = accept.encoding(req.headers["accept-encoding"], ["br", "gzip"]);
log.debug(`decided response encoding is ${encoding} from "${req.headers["accept-encoding"]}"`);
log.debug(`ssr request: ${pretendToBeUrl}`);
try {
const { content, ttRenderMs } = await ssr({
url: pretendToBeUrl,
fetch: fetchFromUrl,
timeout: 5000,
blacklist: cmdline.blacklist,
whiteResources: ["document", "script", "xhr", "fetch"],
encoding: encoding,
copyToDir: cmdline.copyToDir
});
res.set({
"Server-Timing": `Prerender;dur=${ttRenderMs};desc="Headless render time (ms)"`,
"Content-Encoding": encoding,
"Content-Length": content.length,
"Content-Type": "text/html"
});
return res.status(200).send(content);
} catch(err) {
log.error("render error", err.message);
return res.status(404).send("error with rendering");
}
} else {
// Pass on to the proxy
return next();
}
};
const sendToProxy = createProxyMiddleware({
target: "http://localhost:9", // This is the default route if router returns falsy. It's the discard port. https://en.wikipedia.org/wiki/Discard_Protocol
changeOrigin: true,
ws: false,
secure: true,
onProxyReq: (proxyReq, req, res) => {
log.debug(`proxying ${req.protocol}://${req.get('Host')}${req.originalUrl}`);
},
router: (req) => {
const proxyFrom = `${req.protocol}://${req.get('Host')}`;
const proxyTo = proxyMap.get(proxyFrom);
log.debug(`proxy router ${proxyFrom} to ${proxyTo}`);
return proxyTo;
}
});
// Redirect GET on all routes and redirect them to either the
// SSR version or send along to the proxy for the authoritive
// answer.
app.get("*", preRenderUrl, sendToProxy);
async function runEarlyPreRender() {
// Run any initial rendering that is required.
for(let i = 0; i < cmdline.early.length; ++i) {
const urlToRender = cmdline.early[i];
log.debug(`premptive rendering for ${urlToRender}`);
const renderUrl = new url.URL(urlToRender);
const protHostPort = `${renderUrl.origin}`;
const fetchFromUrl = `${proxyMap.get(protHostPort)}${renderUrl.pathname}${renderUrl.search}${renderUrl.hash}`;
try {
await ssr({
url: urlToRender,
fetch: fetchFromUrl,
timeout: 5000,
blacklist: cmdline.blacklist,
whiteResources: ["document", "script", "xhr", "fetch"], // FIXME: used in 2 places.
encoding: "gzip", // doesn't matter so picking something at random.
copyToDir: cmdline.copyToDir
});
} catch(err) {
log.error(`unable to early render ${urlToRender} via ${fetchFromUrl}: ${err.message}`);
}
}
}
async function init() {
// Secure server or not?
let server = app;
if(cmdline.key && cmdline.cert && cmdline.server) {
log.info("Starting secure server");
const privateKey = fs.readFileSync(cmdline.key, "utf8");
const certificate = fs.readFileSync(cmdline.cert, "utf8");
server = https.createServer({
key: privateKey,
cert: certificate
}, app);
}
if(cmdline.drop) {
const ids = cmdline.drop.split(":");
log.debug("dropping privileges to %s based off %s:%s", cmdline.drop, ids[0], ids[1]);
process.setgid(ids[1]);
process.setuid(ids[0]);
log.debug("now %s:%s", process.getuid(), process.getgid());
}
await startBrowser();
await runEarlyPreRender();
// Make the server listen and then we're off to the races.
if(cmdline.server) {
server.listen(cmdline.port,
() => {
log.info("Server started on port %d. Press Ctrl+C to quit", cmdline.port);
});
} else {
console.log("Done preredering all files. Exiting.");
// Indicate that we'd like the program to exit when it can.
process.exit(0);
}
}
init();