Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ website serves the data directly over the internal ALB, which leaves plain
layout, and satori does layout in-process. Roughly 100-300ms instead of ~5.7s,
and no headless Chrome.

Set `RENDERER=satori` to enable it. Everything uses Chrome if the variable is
unset.

Profiles are the harder of the two, because the page they replace isn't just
Expand Down Expand Up @@ -134,9 +133,6 @@ soft and the image is generated as normal.
| --- | --- | --- |
| `IMAGE_BUCKET` | `exercism-v3-assets` | Bucket holding generated images |
| `IMAGE_KEY_PREFIX` | `generated-images` | Key prefix within that bucket |
| `NAVIGATION_TIMEOUT_MS` | `6000` | Page navigation timeout (Chrome only) |
| `SELECTOR_TIMEOUT_MS` | `6000` | Timeout waiting for the content selector (Chrome only) |
| `RENDERER` | unset | Set to `satori` to draw solution images without a browser |

The two timeouts must stay comfortably below the Lambda's own timeout (20s).
puppeteer defaults both to 30s, which is *longer*, meaning a hung render burned
Expand Down
68 changes: 6 additions & 62 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
const fs = require("fs");
const crypto = require("crypto");
const puppeteer = require("puppeteer-core");
const chromium = require("@sparticuz/chromium");
const {
S3Client,
GetObjectCommand,
Expand All @@ -10,12 +7,7 @@ const {
const satoriRenderer = require("./satori_renderer");
const profileRenderer = require("./profile_renderer");

const imagePath = "/tmp/screenshot.jpg";
const baseUrl = "https://exercism.org";

// The satori renderer reads its payload over the internal ALB, so it never
// leaves the VPC. The Chrome path still uses the public site: it's
// screenshotting a rendered page, not reading a payload.
// Payloads are read over the internal ALB, so a render never leaves the VPC.
const internalBaseUrl = process.env.INTERNAL_BASE_URL || "https://internal.exercism.org";

// Writing through to S3 makes the cost of generating an image a function of how
Expand All @@ -26,17 +18,8 @@ const keyPrefix = process.env.IMAGE_KEY_PREFIX || "generated-images";

const s3 = new S3Client({});

// Both must stay under the Lambda's 20s timeout; puppeteer's own defaults are
// 30s, so a hung render burned the full 20s at 2GB rather than failing fast.
const navigationTimeout = parseInt(process.env.NAVIGATION_TIMEOUT_MS || "6000", 10);
const selectorTimeout = parseInt(process.env.SELECTOR_TIMEOUT_MS || "6000", 10);

const legacyMaxAge = 86400;

// Off by default so the browserless output can be eyeballed against the Chrome
// path before it's enabled.
const satoriEnabled = process.env.RENDERER === "satori";

const solutionRegex = /^\/tracks\/(?<track_slug>.+?)\/exercises\/(?<exercise_slug>.+?)\/solutions\/(?<user_handle>.+?)(?:-\d{10})?\.jpg$/;
const profileRegex = /^\/profiles\/(?<user_handle>.+?)(?:-\d{10})?\.jpg$/;

Expand All @@ -49,8 +32,6 @@ function rawPathToScreenshotData(rawPath) {
kind: "solution",
url: `${baseUrl}/images/solutions/${track_slug}/${exercise_slug}/${user_handle}`,
dataUrl: `${internalBaseUrl}/spi/solution_image_data/${track_slug}/${exercise_slug}/${user_handle}`,
imageSelector: "#image-content",
waitForSelector: "#image-content .c-code-pane",
};
}

Expand All @@ -62,8 +43,6 @@ function rawPathToScreenshotData(rawPath) {
kind: "profile",
url: `${baseUrl}/images/profiles/${user_handle}`,
dataUrl: `${internalBaseUrl}/spi/profile_image_data/${user_handle}`,
imageSelector: "#image-content",
waitForSelector: "#image-content #contributions-chart",
};
}

Expand Down Expand Up @@ -151,51 +130,16 @@ async function writeToS3(key, { body, contentType }, cacheControl) {
}
}

async function generateImage({ url, imageSelector, waitForSelector }) {
const browser = await puppeteer.launch({
executablePath: await chromium.executablePath(),
headless: chromium.headless,
ignoreHTTPSErrors: true,
defaultViewport: { ...chromium.defaultViewport, deviceScaleFactor: 2 },
args: [
...chromium.args,
"--hide-scrollbars",
"--disable-web-security",
"--high-dpi-support=1",
],
});

// Closed on the way out, or a failed render leaks it into the next warm
// invocation.
try {
const page = await browser.newPage();
page.setDefaultNavigationTimeout(navigationTimeout);

await page.goto(url);
await page.waitForSelector(waitForSelector, { timeout: selectorTimeout });

const image = await page.$(imageSelector);
await image.screenshot({
path: imagePath,
type: "jpeg",
quality: 80,
});

return { body: fs.readFileSync(imagePath), contentType: "image/jpg" };
} finally {
await browser.close();
}
}

const satoriRenderers = {
const renderers = {
solution: satoriRenderer.generate,
profile: profileRenderer.generate,
};

function rendererFor(screenshotData) {
if (satoriEnabled) return satoriRenderers[screenshotData.kind] || generateImage;
function rendererFor({ kind }) {
const renderer = renderers[kind];
if (!renderer) throw new Error(`No renderer for image kind '${kind}'.`);

return generateImage;
return renderer;
}

exports.handler = async (event) => {
Expand Down
Loading