UNPKG

remix-og-image

Version:

Build-time in-browser Open Graph image generation plugin for Remix.

616 lines (611 loc) 21.3 kB
import { OPEN_GRAPH_USER_AGENT_HEADER } from "./chunk-MIGESNJM.js"; // src/plugin.ts import fs from "node:fs"; import path from "node:path"; import { PassThrough } from "node:stream"; import { finished } from "node:stream/promises"; import { createServer, resolveConfig, normalizePath } from "vite"; import { parse as esModuleLexer } from "es-module-lexer"; import { decode } from "turbo-stream"; import sharp from "sharp"; import { DeferredPromise } from "@open-draft/deferred-promise"; import { deadCodeElimination, findReferencedIdentifiers } from "babel-dead-code-elimination"; import { compile } from "path-to-regexp"; import { launch } from "puppeteer"; // src/performance.ts import { performance as nativePerformance, PerformanceObserver } from "node:perf_hooks"; var { LOG_PERFORMANCE } = process.env; if (LOG_PERFORMANCE) { const observer = new PerformanceObserver((items) => { items.getEntries().forEach((entry) => { console.log(entry); }); }); observer.observe({ entryTypes: ["measure"], buffered: true }); } var performance = LOG_PERFORMANCE ? nativePerformance : { mark() { }, measure() { } }; // src/babel.ts import { parse } from "@babel/parser"; import * as t from "@babel/types"; import { createRequire } from "node:module"; var require2 = createRequire(import.meta.url); var _traverse = require2("@babel/traverse"); var traverse = _traverse.default; var _generate = require2("@babel/generator"); var generate = _generate.default; // src/plugin.ts var PLUGIN_NAME = "remix-og-image-plugin"; var EXPORT_NAME = "openGraphImage"; var CACHE_DIR = path.resolve("node_modules/.cache/remix-og-image"); var CACHE_MANIFEST = path.resolve(CACHE_DIR, "manifest.json"); var CACHE_RESULTS_DIR = path.resolve(CACHE_DIR, "output"); function openGraphImage(options) { if (path.isAbsolute(options.outputDirectory)) { throw new Error( `Failed to initialize plugin: expected "outputDirectory" to be a relative path but got "${options.outputDirectory}". Please make sure it starts with "./".` ); } const format = options.format || "jpeg"; const cache = new Cache(); const browserPromise = new DeferredPromise(); const vitePreviewPromise = new DeferredPromise(); const viteConfigPromise = new DeferredPromise(); const remixContextPromise = new DeferredPromise(); const routesWithImages = /* @__PURE__ */ new Set(); async function fromRemixApp(...paths) { const remixContext = await remixContextPromise; return path.resolve(remixContext.remixConfig.appDirectory, ...paths); } async function fromViteBuild(...paths) { const remixContext = await remixContextPromise; return path.resolve( remixContext.remixConfig.buildDirectory, "client", ...paths ); } async function fromOutputDirectory(...paths) { return fromViteBuild(options.outputDirectory, ...paths); } async function generateOpenGraphImages(route, browser2, appUrl) { if (!route.path) { return []; } performance.mark(`generate-image-${route.file}-start`); const cacheEntry = cache.get(route.file); const routeStats = await fs.promises.stat(await fromRemixApp(route.file)).catch((error) => { console.log('Failed to read stats for route "%s"', route.file, error); throw error; }); const routeLastModifiedAt = routeStats.mtimeMs; if (cacheEntry) { const hasRouteChanged = routeLastModifiedAt > cacheEntry.routeLastModifiedAt; if (!hasRouteChanged) { await Promise.all( cacheEntry.images.map((cachedImage) => { const cachedImagePath = path.resolve( CACHE_RESULTS_DIR, cachedImage.name ); if (fs.existsSync(cachedImagePath)) { return writeImage({ name: cachedImage.name, path: cachedImage.outputPath, stream: fs.createReadStream(cachedImagePath) }); } }) ); return []; } } const remixContext = await remixContextPromise; const createRoutePath = compile(route.path); performance.mark(`generate-image-${route.id}-loader-start`); const loaderData = await getLoaderData( route, appUrl, remixContext.useSingleFetch ); performance.mark(`generate-image-${route.id}-loader-end`); performance.measure( `generate-image-${route.id}:loader`, `generate-image-${route.id}-loader-start`, `generate-image-${route.id}-loader-end` ); const images = []; await Promise.all( loaderData.map(async (data) => { performance.mark(`generate-image-${route.id}-${data.name}-start`); performance.mark( `generate-image-${route.id}-${data.name}-new-page-start` ); const page = await browser2.newPage(); const mediaFeatures = options.browser?.mediaFeatures; if (mediaFeatures) { await page.emulateMediaFeatures( Object.entries(mediaFeatures).map(([name, value]) => { return { name, value }; }) ); } performance.mark(`generate-image-${route.id}-${data.name}-new-page-end`); performance.measure( `generate-image-${route.id}-${data.name}:new-page`, `generate-image-${route.id}-${data.name}-new-page-start`, `generate-image-${route.id}-${data.name}-new-page-end` ); try { const pageUrl = new URL(createRoutePath(data.params), appUrl).href; performance.mark( `generate-image-${route.id}-${data.name}-pageload-start` ); await Promise.all([ page.goto(pageUrl, { waitUntil: "domcontentloaded" }), // Set viewport to a 5K device equivalent. // This is more than enough to ensure that the OG image is visible. page.setViewport({ width: 5120, height: 2880, // Use a larger scale factor to get a crisp image. deviceScaleFactor: 2 }) ]); performance.mark( `generate-image-${route.id}-${data.name}-pageload-end` ); performance.measure( `generate-image-${route.id}-${data.name}:pageload`, `generate-image-${route.id}-${data.name}-pageload-start`, `generate-image-${route.id}-${data.name}-pageload-end` ); const ogImageBoundingBox = await page.$(options.elementSelector).then(async (element) => { if (!element) { return; } await element.scrollIntoView(); return element.boundingBox(); }); if (!ogImageBoundingBox) { return []; } performance.mark( `generate-image-${route.id}-${data.name}-screenshot-start` ); const imageBuffer = await page.screenshot({ type: format, quality: 100, encoding: "binary", // Set an explicit `clip` boundary for the screenshot // to capture only the image and ignore any otherwise // present UI, like the layout. clip: ogImageBoundingBox, optimizeForSpeed: true }); performance.mark( `generate-image-${route.id}-${data.name}-screenshot-end` ); performance.measure( `generate-image-${route.id}-${data.name}:screenshot`, `generate-image-${route.id}-${data.name}-screenshot-start`, `generate-image-${route.id}-${data.name}-screenshot-end` ); let imageStream = sharp(imageBuffer); switch (format) { case "jpeg": { imageStream = imageStream.jpeg({ quality: 100, progressive: true }); break; } case "png": { imageStream = imageStream.png({ compressionLevel: 9, adaptiveFiltering: true }); break; } case "webp": { imageStream = imageStream.webp({ lossless: true, smartSubsample: true, quality: 100, preset: "picture" }); break; } } const imageName = `${data.name}.${format}`; images.push({ name: imageName, path: await fromOutputDirectory(imageName), stream: imageStream }); } finally { await page.close({ runBeforeUnload: false }); performance.mark(`generate-image-${route.id}-${data.name}-end`); performance.measure( `generate-image-${route.id}-${data.name}`, `generate-image-${route.id}-${data.name}-start`, `generate-image-${route.id}-${data.name}-end` ); } }) ); performance.mark(`generate-image-${route.id}-end`); performance.measure( `generate-image-${route.id}`, `generate-image-${route.id}-start`, `generate-image-${route.id}-end` ); cache.set(route.file, { routeLastModifiedAt, images: images.map((image) => ({ name: image.name, outputPath: image.path })) }); return images; } async function writeImage(image) { if (options.writeImage) { return await options.writeImage({ stream: image.stream }); } const directoryName = path.dirname(image.path); await Promise.all([ ensureDirectory(CACHE_RESULTS_DIR), ensureDirectory(directoryName) ]); await ensureDirectory(directoryName); const passthrough = new PassThrough(); const destWriteStream = fs.createWriteStream(image.path); const cacheWriteStream = fs.createWriteStream( path.resolve(CACHE_RESULTS_DIR, image.name) ); image.stream.pipe(passthrough); passthrough.pipe(destWriteStream); passthrough.pipe(cacheWriteStream); await Promise.all([finished(destWriteStream), finished(cacheWriteStream)]); console.log(`Generated OG image at "${image.path}".`); } performance.mark("plugin-start"); return { name: PLUGIN_NAME, apply: "build", async buildStart() { const viteConfig = await viteConfigPromise; await cache.open(path.resolve(viteConfig.root, CACHE_MANIFEST)); }, configResolved(config) { viteConfigPromise.resolve(config); const reactRouterContext = Reflect.get( config, "__reactRouterPluginContext" ); if (reactRouterContext) { remixContextPromise.resolve({ remixConfig: reactRouterContext.reactRouterConfig, useSingleFetch: true }); return; } const remixContext = Reflect.get(config, "__remixPluginContext"); if (typeof remixContext === "undefined") { throw new Error( `Failed to apply "remix-og-image" plugin: no Remix context found. Did you forget to use the Remix plugin in your Vite configuration?` ); } remixContextPromise.resolve({ remixConfig: remixContext.remixConfig, useSingleFetch: !!Reflect.get( remixContext.remixConfig.future, "unstable_singleFetch" ) || !!Reflect.get(remixContext.remixConfig.future, "v3_singleFetch") }); }, async transform(code, id, options2 = {}) { const remixContext = await remixContextPromise; if (!remixContext) { return; } const routePath = normalizePath( path.relative(remixContext.remixConfig.appDirectory, id) ); const route = Object.values(remixContext.remixConfig.routes).find( (route2) => { return normalizePath(route2.file) === routePath; } ); if (!route) { return; } const [, routeExports] = esModuleLexer(code); const hasSpecialExport = routeExports.findIndex((e) => e.n === EXPORT_NAME) !== -1; if (!hasSpecialExport) { return; } if (!options2.ssr) { const ast = parse(code, { sourceType: "module" }); const refs = findReferencedIdentifiers(ast); traverse(ast, { ExportNamedDeclaration(path2) { if (t.isFunctionDeclaration(path2.node.declaration) && t.isIdentifier(path2.node.declaration.id) && path2.node.declaration.id.name === EXPORT_NAME) { path2.remove(); } } }); deadCodeElimination(ast, refs); return generate(ast, { sourceMaps: true, sourceFileName: id }, code); } if (routesWithImages.size === 0) { browserPromise.resolve(getBrowserInstance()); vitePreviewPromise.resolve( runVitePreviewServer(await viteConfigPromise) ); } routesWithImages.add(route); }, // Use `writeBundle` and not `closeBundle` so the image generation // time is counted toward the total build time. writeBundle: { order: "post", async handler() { const viteConfig = await viteConfigPromise; const isBuild = viteConfig.command === "build"; const isServerBuild = viteConfig.build.rollupOptions.input === "virtual:remix/server-build" || // react-router viteConfig.build.rollupOptions.input === "virtual:react-router/server-build"; if (isBuild && /** * @fixme This is a hacky way of knowing the build end. * The problem is that `closeBundle` will trigger MULTIPLE times, * as there are multiple bundles Remix builds (client, server, etc). * This plugin has to run after the LASTMOST bundle. */ isServerBuild) { console.log( `Generating OG images for ${routesWithImages.size} route(s): ${Array.from(routesWithImages).map((route) => ` - ${route.id}`).join("\n")} ` ); const [browser2, server] = await Promise.all([ browserPromise, /** * @fixme Vite preview server someties throws: * "Error: The server is being restarted or closed. Request is outdated." * when trying to navigate to it. It requires a refresh to work. */ vitePreviewPromise ]); const appUrl = new URL(server.resolvedUrls?.local?.[0]); const pendingScreenshots = []; for (const route of routesWithImages) { pendingScreenshots.push( generateOpenGraphImages(route, browser2, appUrl).then((images) => Promise.all(images.map(writeImage))).catch((error) => { throw new Error( `Failed to generate OG image for route "${route.id}".`, { cause: error } ); }) ); } const results = await Promise.allSettled(pendingScreenshots); await Promise.all([server.close(), browser2.close(), cache.close()]); let hasErrors = false; results.forEach((result) => { if (result.status === "rejected") { hasErrors = true; console.error(result.reason); } }); if (hasErrors) { throw new Error( "Failed to generate OG images. Please see the errors above." ); } performance.mark("plugin-end"); performance.measure("plugin", "plugin-start", "plugin-end"); } } } }; } var browser; async function getBrowserInstance(options = {}) { if (browser) { return browser; } performance.mark("browser-launch-start"); browser = await launch({ headless: true, args: [ "--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-software-rasterizer" ], executablePath: options.executablePath }); performance.mark("browser-launch-end"); performance.measure( "browser-launch", "browser-launch-start", "browser-launch-end" ); return browser; } async function runVitePreviewServer(viteConfig) { process.env.NODE_ENV = "development"; performance.mark("vite-preview-resolve-config-start"); const previewViteConfig = await resolveConfig( { root: viteConfig.root, configFile: viteConfig.configFile, logLevel: "error" }, "serve", // Using `production` mode is important. // It will skip all the built-in development-oriented plugins in Vite. "production", "development", false ); performance.mark("vite-preview-resolve-config-end"); performance.measure( "vite-preview-resolve-config", "vite-preview-resolve-config-start", "vite-preview-resolve-config-end" ); performance.mark("vite-preview-server-start"); const server = await createServer(previewViteConfig.inlineConfig); performance.mark("vite-preview-server-end"); performance.measure( "vite-preview-server", "vite-preview-server-start", "vite-preview-server-end" ); return server.listen(); } var Cache = class extends Map { cachePath; constructor() { super(); } async open(cachePath) { if (this.cachePath) { return; } this.cachePath = cachePath; if (fs.existsSync(this.cachePath)) { const cacheContent = await fs.promises.readFile(this.cachePath, "utf-8").then(JSON.parse).catch(() => ({})); for (const [key, value] of Object.entries(cacheContent)) { this.set(key, value); } } } async close() { if (!this.cachePath) { throw new Error(`Failed to close cache: cache is not open`); } const cacheContent = JSON.stringify(Object.fromEntries(this.entries())); const baseDirectory = path.dirname(this.cachePath); if (!fs.existsSync(baseDirectory)) { await fs.promises.mkdir(baseDirectory, { recursive: true }); } return fs.promises.writeFile(this.cachePath, cacheContent, "utf8"); } }; async function getLoaderData(route, appUrl, useSingleFetch) { const url = createResourceRouteUrl(route, appUrl, useSingleFetch); const response = await fetch(url, { headers: { "user-agent": OPEN_GRAPH_USER_AGENT_HEADER } }).catch((error) => { throw new Error( `Failed to fetch Open Graph image data for route "${url.href}": ${error}` ); }); if (!response.ok) { throw new Error( `Failed to fetch Open Graph image data for route "${url.href}": loader responsed with ${response.status}` ); } if (!response.body) { throw new Error( `Failed to fetch Open Graph image data for route "${url.href}": loader responsed with no body. Did you forget to throw \`json(openGraphImage())\` in your loader?` ); } const responseContentType = response.headers.get("content-type") || ""; const expectedContentTypes = useSingleFetch ? ["text/x-turbo", "text/x-script"] : ["application/json"]; if (!expectedContentTypes.includes(responseContentType)) { throw new Error( `Failed to fetch Open Graph image data for route "${url.href}": loader responsed with invalid content type ("${responseContentType}"). Did you forget to throw \`json(openGraphImage())\` in your loader?` ); } const data = await consumeLoaderResponse(response, route, useSingleFetch); if (!Array.isArray(data)) { throw new Error( `Failed to fetch Open Graph image data for route "${url.href}": loader responded with invalid response. Did you forget to throw \`json(openGraphImage())\` in your loader?` ); } return data; } function createResourceRouteUrl(route, appUrl, useSingleFetch) { if (!route.path) { throw new Error( `Failed to create resource route URL for route "${route.id}": route has no path` ); } const url = new URL(route.path, appUrl); if (useSingleFetch) { url.pathname += ".data"; url.searchParams.set("_route", route.id); } else { url.searchParams.set("_data", route.id); } return url; } async function decodeTurboStreamResponse(response) { if (!response.body) { throw new Error( `Failed to decode turbo-stream response: response has no body` ); } const bodyStream = await decode(response.body); await bodyStream.done; const decodedBody = bodyStream.value; if (!decodedBody) { throw new Error(`Failed to decode turbo-stream response`); } return decodedBody; } async function consumeLoaderResponse(response, route, useSingleFetch) { if (!response.body) { throw new Error(`Failed to read loader response: response has no body`); } if (useSingleFetch) { const decodedBody = await decodeTurboStreamResponse(response); const routePayload = decodedBody[route.id]; if (!routePayload) { throw new Error( `Failed to consume loader response for route "${route.id}": route not found in decoded response` ); } const data = routePayload.data; if (!data) { throw new Error( `Failed to consume loader response for route "${route.id}": route has no data` ); } return data; } return response.json(); } async function ensureDirectory(directory) { if (fs.existsSync(directory)) { return; } await fs.promises.mkdir(directory, { recursive: true }); } export { openGraphImage };