From 8f83ac412c397d43f2e1afb913f183d101b12dd8 Mon Sep 17 00:00:00 2001 From: Matchu Date: Fri, 14 May 2021 19:51:48 -0700 Subject: [PATCH] Add sharing meta tags to outfit pages Meta tags are a bit tricky in apps built with `create-react-app`! While some bots like Google are able to render the full page when crawling, not all bots are. Most will just see the empty-ish index.html that would normally load up the application. But we want outfit sharing to work! And be cool! And use our new outfit thumbnails! In this change, we add a new server-side rendering API route to handle `/outfits/:id`. It's very weak server-side rendering: it just loads index.html, and makes a few small tweaks inside the `` tag. But it should be enough for sharing to work in clients that support the basics of Open Graph, which I think most major providers respect! (I know Twitter has their own tags, but it also respects the basics of OG, so let's see whether there's anything we end up _wanting_ to tweak or not!) --- api/outfitPageSSR.js | 141 +++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + vercel.json | 1 + yarn.lock | 2 +- 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 api/outfitPageSSR.js diff --git a/api/outfitPageSSR.js b/api/outfitPageSSR.js new file mode 100644 index 0000000..4dfba33 --- /dev/null +++ b/api/outfitPageSSR.js @@ -0,0 +1,141 @@ +/** + * /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. + * + * 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", +}); + +import escapeHtml from "escape-html"; +import fetch from "node-fetch"; +import { promises as fs } from "fs"; + +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); + } + + // 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(outfit.name)} | 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-2020.openneo.net/api/outfitImage?size=300&id=${encodeURIComponent( + outfit.id + )}&updatedAt=${updatedAtTimestamp}`; + 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) { + if (process.env.NODE_ENV === "development") { + const htmlFromDevServer = await fetch( + "http://localhost:3000/" + ).then((res) => res.text()); + cachedIndexPageHtml = htmlFromDevServer; + } else { + const htmlFromFile = await fs.readFile("../build/index.html", "utf8"); + cachedIndexPageHtml = htmlFromFile; + } + } + + return cachedIndexPageHtml; +} + +function reject(res, message, status = 400) { + res.setHeader("Content-Type", "text/plain"); + 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; diff --git a/package.json b/package.json index 8999d82..82e9414 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "canvas": "^2.6.1", "dataloader": "^2.0.0", "dompurify": "^2.2.0", + "escape-html": "^1.0.3", "framer-motion": "^4.1.11", "graphql": "^15.5.0", "honeycomb-beeline": "^2.2.0", diff --git a/vercel.json b/vercel.json index 7689ad6..d941085 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,6 @@ { "routes": [ + { "src": "/outfits/(?[^/]*)", "dest": "/api/outfitPageSSR.js?id=$id" }, { "handle": "filesystem" }, diff --git a/yarn.lock b/yarn.lock index 7b256d1..fc43cec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9542,7 +9542,7 @@ escape-goat@^2.0.0: resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== -escape-html@~1.0.3: +escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=