fastify-prerender-plugin
Version:
Prerender SPA pages for bots
101 lines (97 loc) • 3.37 kB
JavaScript
import Crypto from 'node:crypto';
import Fs from 'node:fs';
import Path from 'node:path';
import { fastifyPlugin } from 'fastify-plugin';
import { isbot } from 'isbot';
import sanitize from 'sanitize-filename';
import tmp from 'tmp';
import { fileURLToPath } from 'node:url';
import { isMainThread, workerData, parentPort, Worker } from 'node:worker_threads';
import { lightpanda } from '@lightpanda/browser';
async function requestFromBrowser(url) {
return new Promise((resolve, reject) => {
const worker = new Worker(fileURLToPath(import.meta.url), {
workerData: { url }
});
worker.on("message", resolve);
worker.on("error", reject);
});
}
if (!isMainThread) {
const options = {
dump: true,
disableHostVerification: false
};
lightpanda.fetch(workerData.url, options).then((response) => {
parentPort?.postMessage(response.toString());
});
}
const prerenderPlugin = fastifyPlugin(
(app, options, done) => {
const tmpobj = tmp.dirSync();
app.addHook("onRequest", async (request, reply) => {
const userAgent = request.headers["user-agent"] ?? "";
if (userAgent.startsWith("Lightpanda/")) {
return;
}
const requestFromBot = isbot(userAgent) || userAgent.toLowerCase().startsWith("facebookexternalhit") || userAgent.toLowerCase().startsWith("whatsapp") || userAgent.toLowerCase().startsWith("twitterbot");
if (!requestFromBot || request.method !== "GET") {
return;
}
request.log.info({ requestFromBot });
let matches = false;
const requestPathname = request.url.split("?")[0];
for (const url2 of options.urls) {
if (typeof url2 === "string") {
if (url2 === requestPathname) {
matches = true;
break;
}
} else {
if (url2.test(request.url)) {
matches = true;
break;
}
}
}
if (!matches) {
return;
}
const url = `http://${options.host ?? "localhost"}:${options.port}${request.url}`;
const urlObj = new URL(url);
const pathname = sanitize(urlObj.pathname.replace(/\//g, "_"));
const queryString = urlObj.search;
let filename = pathname || "index";
if (queryString) {
const queryHash = Crypto.createHash("md5").update(queryString).digest("hex");
filename = `${filename}_${queryHash}`;
}
filename = `${filename}.html`;
const filepath = Path.join(options.tmpPath ?? tmpobj.name, filename);
if (Fs.existsSync(filepath)) {
const fileStat = Fs.statSync(filepath);
const fileAgeInMinutes = (Date.now() - fileStat.mtime.getTime()) / 1e3 / 60;
if (fileAgeInMinutes <= 5) {
return reply.status(200).type("text/html").send(Fs.readFileSync(filepath));
}
Fs.rmSync(filepath);
}
request.log.info(`request-from-browser: ${url}`);
const html = await requestFromBrowser(url).catch((error) => {
console.error(error);
}) ?? "";
try {
Fs.writeFileSync(filepath, html);
} catch (_error) {
request.log.error(`Couldn't write cache in ${filepath}`);
}
reply.status(200).type("text/html").send(html);
});
done();
},
{
fastify: "^5.x",
name: "fastify-prerender"
}
);
export { prerenderPlugin, requestFromBrowser };