/** * /api/outfitPageSSR also serves the initial request for /outfits/:id, to * add title and meta tags. This primarily for sharing, like on Discord or * Twitter or Facebook! * * The route is configured in vercel.json, at the project root. * * To be honest, we probably should have built Impress 2020 on Next.js, and * then we'd be getting realistic server-side rendering across practically the * whole app very cheaply. But this is a good hack for what we have! * * TODO: We could add the basic outfit page layout and image preview, to use * SSR to decrease time-to-first-content for the end-user, too… */ const beeline = require("honeycomb-beeline")({ writeKey: process.env["HONEYCOMB_WRITE_KEY"], dataset: process.env["NODE_ENV"] === "production" ? "Dress to Impress (2020)" : "Dress to Impress (2020, dev)", serviceName: "impress-2020-gql-server", disableInstrumentationOnLoad: true, }); import escapeHtml from "escape-html"; import fetch from "node-fetch"; import connectToDb from "../../src/server/db"; import { normalizeRow } from "../../src/server/util"; async function handle(req, res) { // Load index.html as our initial page content. If this fails, it probably // means something is misconfigured in a big way; we don't have a great way // to recover, and we'll just show an error message. let initialHtml; try { initialHtml = await loadIndexPageHtml(); } catch (e) { console.error("Error loading index.html:", e); return reject(res, "Sorry, there was an error loading this outfit page!"); } // Load the given outfit by ID. If this fails, it's possible that it's just a // problem with the SSR, and the client will be able to handle it better // anyway, so just show the standard index.html and let the app load // normally, as if there was no error. (We'll just log it.) let outfit; try { outfit = await loadOutfitData(req.query.id); } catch (e) { console.error("Error loading outfit data:", e); return sendHtml(res, initialHtml, 200); } // Similarly, if the outfit isn't found, we just show index.html - but with a // 404 and a gentler log message. if (outfit == null) { console.info(`Outfit not found: ${req.query.id}`); return sendHtml(res, initialHtml, 404); } const outfitName = outfit.name || "Untitled outfit"; // Okay, now let's rewrite the HTML to include some outfit data! // // WARNING!!! // Be sure to always use `escapeHtml` when inserting user data!! // WARNING!!! // let html = initialHtml; // Add the outfit name to the title. html = html.replace( /(.*)<\/title>/, `<title>${escapeHtml(outfitName)} | Dress to Impress` ); // Add sharing meta tags just before the tag. const updatedAtTimestamp = Math.floor( new Date(outfit.updatedAt).getTime() / 1000 ); const outfitUrl = `https://impress-2020.openneo.net/outfits` + `/${encodeURIComponent(outfit.id)}`; const imageUrl = `https://impress-outfit-images.openneo.net/outfits` + `/${encodeURIComponent(outfit.id)}` + `/v/${encodeURIComponent(updatedAtTimestamp)}` + `/600.png`; const metaTags = ` `; html = html.replace(/<\/head>/, `${metaTags}`); console.info(`Successfully SSR'd outfit ${outfit.id}`); return sendHtml(res, html); } async function loadOutfitData(id) { const db = await connectToDb(); const [rows] = await db.query(`SELECT * FROM outfits WHERE id = ?;`, [id]); if (rows.length === 0) { return null; } return normalizeRow(rows[0]); } let cachedIndexPageHtml = null; async function loadIndexPageHtml() { if (cachedIndexPageHtml == null) { // Request the same built copy of index.html that we're already serving at // our homepage. const homepageUrl = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}/` : process.env.NODE_ENV === "development" ? "http://localhost:3000/" : "https://impress-2020.openneo.net/"; const liveIndexPageHtml = await fetch(homepageUrl).then((res) => res.text() ); cachedIndexPageHtml = liveIndexPageHtml; } return cachedIndexPageHtml; } function reject(res, message, status = 400) { res.setHeader("Content-Type", "text/plain; charset=utf8"); return res.status(status).send(message); } function sendHtml(res, html, status = 200) { res.setHeader("Content-Type", "text/html"); return res.status(status).send(html); } async function handleWithBeeline(req, res) { beeline.withTrace( { name: "api/outfitPageSSR", operation_name: "api/outfitPageSSR" }, () => handle(req, res) ); } export default handleWithBeeline;