remix-og-image
Version:
Build-time in-browser Open Graph image generation plugin for Remix.
616 lines (611 loc) • 21.3 kB
JavaScript
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
};