UNPKG

remix-og-image

Version:

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

865 lines (727 loc) 25.6 kB
import fs from 'node:fs' import path from 'node:path' import { PassThrough, Readable } from 'node:stream' import { finished } from 'node:stream/promises' import { type Plugin, type ResolvedConfig, type ViteDevServer, createServer, resolveConfig, normalizePath, } from 'vite' import { parse as esModuleLexer } from 'es-module-lexer' import { decode } from 'turbo-stream' import sharp from 'sharp' import type { VitePluginConfig as RemixVitePluginConfig } from '@remix-run/dev' import type { RouteConfigEntry } from '@remix-run/dev/dist/config/routes.js' import { DeferredPromise } from '@open-draft/deferred-promise' import { deadCodeElimination, findReferencedIdentifiers, } from 'babel-dead-code-elimination' import { compile } from 'path-to-regexp' import { Browser, launch } from 'puppeteer' import { performance } from './performance.js' import { parse, traverse, generate, t } from './babel.js' import { OPEN_GRAPH_USER_AGENT_HEADER, type OpenGraphImageData, } from './index.js' interface Options { /** * Selector for the element to capture as the Open Graph image. * @example "#og-image" */ elementSelector: string /** * Relative path to the directory to store generated images. * Relative to the client build assets (e.g. `/build/client`). */ outputDirectory: string /** * Format of the generated image. * @default "jpeg" */ format?: 'jpeg' | 'png' | 'webp' writeImage?: (image: { stream: Readable }) => Promise<void> browser?: { executablePath?: string /** * Custom media features. * Use this to force media features like `prefers-color-scheme` * or `prefers-reduced-motion`. * * @example * mediaFeatures: { * 'prefers-color-scheme': 'dark', * } */ mediaFeatures?: Record<string, string> } } interface RemixPluginContext { remixConfig: Required<RemixVitePluginConfig> useSingleFetch: boolean } interface GeneratedOpenGraphImage { name: string path: string stream: Readable } interface CacheEntry { routeLastModifiedAt: number images: Array<{ name: string outputPath: string }> } const PLUGIN_NAME = 'remix-og-image-plugin' const EXPORT_NAME = 'openGraphImage' const CACHE_DIR = path.resolve('node_modules/.cache/remix-og-image') const CACHE_MANIFEST = path.resolve(CACHE_DIR, 'manifest.json') const CACHE_RESULTS_DIR = path.resolve(CACHE_DIR, 'output') export function openGraphImage(options: Options): Plugin { 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<string, CacheEntry>() const browserPromise = new DeferredPromise<Browser>() const vitePreviewPromise = new DeferredPromise<ViteDevServer>() const viteConfigPromise = new DeferredPromise<ResolvedConfig>() const remixContextPromise = new DeferredPromise<RemixPluginContext>() const routesWithImages = new Set<RouteConfigEntry>() async function fromRemixApp(...paths: Array<string>): Promise<string> { const remixContext = await remixContextPromise return path.resolve(remixContext.remixConfig.appDirectory, ...paths) } async function fromViteBuild(...paths: Array<string>): Promise<string> { const remixContext = await remixContextPromise return path.resolve( remixContext.remixConfig.buildDirectory, 'client', ...paths, ) } async function fromOutputDirectory(...paths: Array<string>): Promise<string> { return fromViteBuild(options.outputDirectory, ...paths) } async function generateOpenGraphImages( route: RouteConfigEntry, browser: Browser, appUrl: URL, ): Promise<Array<GeneratedOpenGraphImage>> { if (!route.path) { return [] } performance.mark(`generate-image-${route.file}-start`) // See if the route already has images generated in the cache. 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 the route hasn't changed, and there are cached generated results, // copy the generated images without spawning the browser, screenshoting, etc. 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), }) } }), ) /** * @fixme If copying the cached images fails for any reason, * the plugin should continue ONLY with the sub-list of images * that excludes those that were successfully copied from the cache. */ return [] } } const remixContext = await remixContextPromise const createRoutePath = compile(route.path) performance.mark(`generate-image-${route.id}-loader-start`) // Fetch all the params data from the route. 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: Array<GeneratedOpenGraphImage> = [] 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 browser.newPage() // Support custom user preferences (media features), // such as forcing a light/dark mode for the app. 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: GeneratedOpenGraphImage) { 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', ) // react-router 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, options = {}) { const remixContext = await remixContextPromise if (!remixContext) { return } const routePath = normalizePath( path.relative(remixContext.remixConfig.appDirectory, id), ) const route = Object.values(remixContext.remixConfig.routes).find( (route) => { return normalizePath(route.file) === routePath }, ) // Ignore non-route modules. if (!route) { return } const [, routeExports] = esModuleLexer(code) // Ignore routes that don't have the root-level special export. const hasSpecialExport = routeExports.findIndex((e) => e.n === EXPORT_NAME) !== -1 if (!hasSpecialExport) { return } // OG image generation must only happen server-side. if (!options.ssr) { // Parse the route module and remove the special export altogether. // This way, it won't be present in the client bundle, and won't affect // "vite-plugin-react" and its HMR. const ast = parse(code, { sourceType: 'module' }) const refs = findReferencedIdentifiers(ast) traverse(ast, { ExportNamedDeclaration(path) { if ( t.isFunctionDeclaration(path.node.declaration) && t.isIdentifier(path.node.declaration.id) && path.node.declaration.id.name === EXPORT_NAME ) { path.remove() } }, }) // Use DCE to remove any references the special export might have had. deadCodeElimination(ast, refs) return generate(ast, { sourceMaps: true, sourceFileName: id }, code) } // Spawn the browser immediately once we detect an OG image route. 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 [browser, 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: Array<Promise<unknown>> = [] for (const route of routesWithImages) { pendingScreenshots.push( generateOpenGraphImages(route, browser, 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(), browser.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') } }, }, } } let browser: Browser | undefined async function getBrowserInstance( options: Options['browser'] = {}, ): Promise<Browser> { 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: ResolvedConfig, ): Promise<ViteDevServer> { /** * @note Force `NODE_ENV` to be "development" for the preview server. * Vite sets certain internal flags based on the environment, and there * is no way to override that. The build is triggered with "production", * but the preview server MUST be "development". This also makes sure * that all the used plugins are instantiated correctly. */ process.env.NODE_ENV = 'development' performance.mark('vite-preview-resolve-config-start') // Use the `resolveConfig` function explicitly because it // allows passing options like `command` and `mode` to Vite. 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() } class Cache<K, V> extends Map<K, V> { private cachePath?: string constructor() { super() } public async open(cachePath: string): Promise<void> { 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 as K, value as V) } } } public async close(): Promise<void> { 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: RouteConfigEntry, appUrl: URL, useSingleFetch?: boolean, ) { 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?`, ) } // Consume the loader response based on the fetch mode. 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 } /** * Create a URL for the given route so it can be queried * as a resource route. Respects Single fetch mode. */ function createResourceRouteUrl( route: RouteConfigEntry, appUrl: URL, useSingleFetch?: boolean, ) { 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' /** * @note The `_routes` parameter is meant for fetching multiple loader * data that match the route. It won't work if you have multiple different, * independent routes, so we still need to fetch the loader data in multiple requests. */ url.searchParams.set('_route', route.id!) } else { // Set the "_data" search parameter so the route can be queried // like a resource route although it renders UI. url.searchParams.set('_data', route.id!) } return url } async function decodeTurboStreamResponse( response: Response, ): Promise<Record<string, { data: unknown }>> { 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 as Record<string, { data: unknown }> if (!decodedBody) { throw new Error(`Failed to decode turbo-stream response`) } return decodedBody } async function consumeLoaderResponse( response: Response, route: RouteConfigEntry, useSingleFetch?: boolean, ): Promise<Array<OpenGraphImageData>> { if (!response.body) { throw new Error(`Failed to read loader response: response has no body`) } // If the app is using Single Fetch, decode the loader // payload properly using the `turbo-stream` package. 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 as any).data as Array<OpenGraphImageData> if (!data) { throw new Error( `Failed to consume loader response for route "${route.id}": route has no data`, ) } return data } // If the app is using the legacy loader response, // read it as JSON (it's not encoded). return response.json() } async function ensureDirectory(directory: string): Promise<void> { if (fs.existsSync(directory)) { return } await fs.promises.mkdir(directory, { recursive: true }) }