impress-2020/pages/api/assetImage.js
Matchu 19f1ec092e Turn on Honeycomb instrumentation again
Well, instrumentation seems to be working fine again! The bug we ran into during commit e5081dab7e is gone. Cool!

I want to be able to see what's making the new box slow. My hypothesis was (and it seems to be right) that communication with the database on the Classic DTI server is slow.

But now that they're on the same Linode account and region, I think I can set up a private VLAN to make them muuuch faster. We'll try it out!
2021-11-26 23:41:22 -08:00

213 lines
6.8 KiB
JavaScript

/**
* /api/assetImage renders a canvas movie to PNG! To do this, we use a headless
* Chromium browser, which renders a special page in the webapp and screenshots
* the displayed canvas.
*
* This is, of course, a relatively heavyweight operation: it's always gonna be
* a bit slow, and consume significant RAM. So, caching is going to be
* important, so that we're not calling this all the time and overloading the
* endpoint!
*
* Parameters:
* - libraryUrl: A https://images.neopets.com/ URL to a JS movie library
* - size: 600, 300, or 150. Determines the output image size.
*/
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",
});
const puppeteer = require("puppeteer");
const genericPool = require("generic-pool");
console.info(`Creating new browser instance`);
const browserPromise = puppeteer.launch({ headless: true });
// We maintain a small pool of browser pages, to manage memory usage. If all
// the pages are already in use, a request will wait for one of them to become
// available.
//
// NOTE: 4 pages is about where our 1-cpu prod environment maxes out. We might
// want to upgrade to the 2-cpu box as we add more pressure though, and
// then maybe we can afford more pages in the pool?
const PAGE_POOL = genericPool.createPool(
{
create: async () => {
console.debug(`Creating a browser page`);
const browser = await browserPromise;
return await browser.newPage();
},
destroy: (page) => {
console.debug(`Closing a browser page`);
page.close();
},
validate: (page) => page.browser().isConnected(),
},
{ min: 4, max: 4, testOnBorrow: true, acquireTimeoutMillis: 15000 }
);
PAGE_POOL.on("factoryCreateError", (error) => console.error(error));
PAGE_POOL.on("factoryDestroyError", (error) => console.error(error));
async function handle(req, res) {
const { libraryUrl, size } = req.query;
if (!libraryUrl) {
return reject(res, "libraryUrl is required");
}
if (!isNeopetsUrl(libraryUrl)) {
return reject(
res,
`libraryUrl must be an HTTPS Neopets URL, but was: ${libraryUrl}`
);
}
if (size !== "600" && size !== "300" && size !== "150") {
return reject(res, `size must be 600, 300, or 150, but was: ${size}`);
}
let imageBuffer;
try {
imageBuffer = await loadAndScreenshotImage(libraryUrl, size);
} catch (e) {
console.error(e);
if (e.name === "TimeoutError") {
return reject(res, `Server under heavy load: ${e.message}`, 503);
}
return reject(res, `Could not load image: ${e.message}`, 500);
}
// TODO: Compress the image?
// Send a long-term cache header, to avoid running this any more than we have
// to! If we make a big change, we'll flush the cache or add a version param.
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
res.setHeader("Content-Type", "image/png");
return res.send(imageBuffer);
}
async function loadAndScreenshotImage(libraryUrl, size) {
// To render the image, we load the /internal/assetImage page in the web app,
// a simple page specifically designed for this API endpoint!
//
// NOTE: If we deploy to a host where localhost:3000 won't work, make this
// configurable with an env var, e.g. process.env.LOCAL_APP_HOST
const assetImagePageUrl = new URL(
"http://localhost:3000/internal/assetImage"
);
assetImagePageUrl.search = new URLSearchParams({
libraryUrl,
size,
}).toString();
console.debug("Getting browser page");
const page = await PAGE_POOL.acquire();
try {
console.debug("Page ready, navigating to: " + assetImagePageUrl.toString());
await page.goto(assetImagePageUrl.toString());
console.debug("Page loaded, awaiting image");
// Start looking for the loaded canvas, *and* for an error message.
// When either one displays, we proceed, either by returning the image if
// present, or raising the error if present.
const imageBufferPromise = screenshotImageFromPage(page);
const errorMessagePromise = readErrorMessageFromPage(page);
let firstResultFromPage;
try {
firstResultFromPage = await Promise.any([
imageBufferPromise.then((imageBuffer) => ({ imageBuffer })),
errorMessagePromise.then((errorMessage) => ({ errorMessage })),
]);
} catch (error) {
if (error.errors) {
// If both promises failed, show all error messages.
throw new Error(error.errors.map((e) => e.message).join(", "));
} else {
throw error;
}
}
if (firstResultFromPage.errorMessage) {
throw new Error(firstResultFromPage.errorMessage);
} else if (firstResultFromPage.imageBuffer) {
return firstResultFromPage.imageBuffer;
} else {
throw new Error(
`Assertion error: Promise.any did not return an errorMessage or an imageBuffer: ` +
`${JSON.stringify(Object.keys(firstResultFromPage))}`
);
}
} finally {
// To avoid memory leaks, we destroy the page when we're done with it.
// The pool will replace it with a fresh one!
PAGE_POOL.destroy(page);
}
}
async function screenshotImageFromPage(page) {
await page.waitForSelector("#asset-image-canvas[data-is-loaded=true]", {
timeout: 10000,
});
const canvas = await page.$("#asset-image-canvas[data-is-loaded=true]");
console.debug("Image loaded, taking screenshot");
const imageBuffer = await canvas.screenshot({
omitBackground: true,
});
console.debug(`Screenshot captured, size: ${imageBuffer.length}`);
return imageBuffer;
}
async function readErrorMessageFromPage(page) {
await page.waitForSelector("#asset-image-error-message", {
timeout: 10000,
});
const errorMessageContainer = await page.$("#asset-image-error-message");
const errorMessage = await errorMessageContainer.innerText();
return errorMessage;
}
function isNeopetsUrl(urlString) {
let url;
try {
url = new URL(urlString);
} catch (e) {
return false;
}
return url.origin === "https://images.neopets.com";
}
function reject(res, message, status = 400) {
res.setHeader("Content-Type", "text/plain; charset=utf8");
return res.status(status).send(message);
}
// Polyfill Promise.any for older Node: https://github.com/ungap/promise-any
Promise.any =
Promise.any ||
function ($) {
return new Promise(function (D, E, A, L) {
A = [];
L = $.map(function ($, i) {
return Promise.resolve($).then(D, function (O) {
return ((A[i] = O), --L) || E({ errors: A });
});
}).length;
});
};
async function handleWithBeeline(req, res) {
beeline.withTrace(
{ name: "api/assetImage", operation_name: "api/assetImage" },
() => handle(req, res)
);
}
export default handleWithBeeline;