Statically Generating Screenshots
The idea of dynamically generating screenshots which meant to be statically served rose while I was exploring different methods of displaying images which needed to be updated on a regular basis.
What I mean by updated is that the image needed to be invalidated after a certain amount of time, creating a new one. This was mainly because I was trying to serve up to date screenshots of apps that were changing very often.
I started by experimenting with using an API endpoint that took screenshot with Chromium, then caching it with HTTP headers. I also used Microlink’s Screenshot API, which was a much simpler solution since it replaced my endpoint with only a few lines of code. However, I came to the conclusion that HTTP caching was not good enough because the first visitor to an uncached image would have to wait 5-7 seconds! Instead, I decided to pivot and focus on serving static images that have no extreme loading times.
The Goal
To give you a little bit of context, what I wanted to achieve is a link preview component. If you are not familiar with what a link preview is, you can check out this article.
Besides a pleasing UI, I wanted to have screenshots for both light and dark theme that were served statically which needed to be small sized and also regularly updated.
An example of a screenshot that may change often is your twitter profile, due to profile/cover picture changes and retweets.
I’ve also prepared a demo app of the link preview component which displays a screenshot of my twitter account. The screenshot from this demo is being recreated every 12 hours.
Prerequisites
If you have never worked with things such as Github Actions, Headless Browsers, making images web-friendly, don’t worry, the next subchapters include basic information about what are we going to use in order to achieve our goal, along-sided by some brief explanations of my thought process.
However, if you are familiar with all these terms, you can skip to the next chapter !
Github Actions
The first thing that came to my mind while I was thinking about a mechanism that is going to automatically generate the screenshots was by using Github Actions.
What Are Github Actions?
Github Actions is a tool mainly used for automating workflows such as: building, testing, deploying (CI/CD). Other than that, they may be used for running workflows on certain events like push, new issue, new release or even as cronjob.
Take your time to read even more about automating workflows.
How Do You Create A Workflow?
Creating a new workflow is made by creating .github/workflows
directory, inside of whom you need to create a yaml
file which is going to be automatically treated as an action by Github once the code is pushed.
Check out Github Actions Quickstart, there you will discover more about creating workflows and viewing the results.
Headless Browsers
Since we said that we are going to automatically generate the screenshots, we are going to need a Headless Browser to do our job.
What is a Headless Browser
A Headless Browser, is a Web Browser without an User Interface. It can perform all the functions that we, the user, usually do on the browser, such as clicking, writing, navigating pages by programatically instructing it to do so.
Common Usage
- Automation Testing - submitting forms, verifying that click fire certain events, keyboard inputs,...
- Layout Testing - even though they are headless, they can interpret HTML/CSS just like any other browser
- Web Scraping
Browserless
Browserless is the Headless Browser that we are going to use which is built on top of Puppeteer.
I’ve chose this due to its strong configuration out of the box, which tricks many applications against their bot detection.
Image Optimisation
Optimisation is such a vast topic to tackle, however what we’re looking for from this article in terms of optimisation is to render small sized images which are being preloaded.
Preloading Images
Preloading an image is a process where you are signalling to the browser that this resource is important and the load needs to be done before actually finding it in the DOM.
<link rel="preload" as="image" href="important_image.png" />
Note that preloading should not be abused since it may lead to larger loading times of the app and also a bad user experience overall.
Keeping It Small
As stated before, when we preload we need to be careful at how many files do we preload and also the size of those files.
Since we’re talking about a link preview, the screenshots shown will be rendered at small sizes (width/height wise). So, one strategy would be to resize them and also save them as webp
format.
One tool that we’re going to use in this example is sharp, a module used for resizing/converting images to web-friendly images.
As I mentioned the term web-friendly, WebP is an image format for the Web that will help us achieve small images sizes (~26% smaller in size compared to PNGs).
Building It
Now that we’ve gone through the expected result of the experiment and also some “technical requirements” that will help us achieve this goal, let’s get our hands dirty.
The source code of the demo that we are going to be building is available here.
Generating the screenshots
Firstly, we need a constant array of objects that will consist of the name (which will be the directory name of both images) and url of the targeted apps that need to be screenshot and also where we want to save those images.
Example:
// lib/generateScreenshots.js
const entries = [
{
name: 'twitter',
url: 'https://twitter.com/_mariusflorescu'
},
{
name: 'github',
url: 'https://github.com/mariusflorescu'
}
]
const screenshotsDir = './public/screenshots'
We also need some utility functions for creating a directory, running a cleanup and also for resizing:
// lib/generateScreenshots.js
const createDir = (dirname) => {
if (!fs.existsSync(dirname)) {
fs.mkdirSync(dirname)
}
}
const cleanup = () => {
if (fs.existsSync(screenshotsDir)) {
fs.rmdirSync(screenshotsDir, { recursive: true })
}
}
const resize = async (buffer, path) => {
try {
await sharp(buffer)
.resize(400, 256, {
fit: 'fill'
})
.toFile(path)
} catch (err) {
console.error(err)
return err
}
}
createDir
- checks if the directory does not exist. If so, it creates a new one.cleanup
- deletes the old screenshot directory.resize
- gets the buffer (image) and path to save as parameters and resizes the buffer to our wanted size and then saves it to the desired path.
After that, we need 2 more functions, one that will take the actual screenshots of an entry from the array and one that does all the setup and iterates through the array and calls the create screenshot function.
Create screenshot function:
// lib/generateScreenshots.js
const createScreenshot = async (browserless, entry) => {
try {
const dirname = `${screenshotsDir}/${entry.name}`
createDir(dirname)
const buffers = await Promise.all([
browserless.screenshot(entry.url, {
colorScheme: 'light',
waitForTimeout: 5000
}),
browserless.screenshot(entry.url, {
colorScheme: 'dark',
waitForTimeout: 5000
})
])
await Promise.all([
resize(buffers[0], `${dirname}/light.webp`),
resize(buffers[1], `${dirname}/dark.webp`)
])
} catch (err) {
console.error(err)
return err
}
}
This function gets the Headless Browser instance as parameter and the entry itself, creates a new directory with the name of the entry, takes the screenshot for both themes and resizes them accordingly.
Generating screenshots function:
// lib/generateScreenshots.js
const generateScreenshots = async () => {
try {
const browserlessFactory = createBrowserless();
const browserless = await browserlessFactory.createContext();
cleanup();
createDir(screenshotsDir);
await Promise.all(
entries.map((entry) => createScreenshot(browserless, entry))
);
await browserless.destroyContext();
await browserlessFactory.close();
} catch (err) {
console.error("Error while generating the screenshots");
console.error(err);
}
}();
This code creates a new instance of a Headless Browser, runs a cleanup, recreate the screenshot directory then iterate through all the entries and creates the screenshots.
All that remains is to add the script to our package.json
.
"screenshots": "node ./generateScreenshots.js"
Feel free to take a look at the file that contains the all the logic necessary to take all the screenshots.
Be aware, this code is for demo purposes and there is room for improvement.
Creating the workflow
Now that we have the script that generates our screenshots, we need to create an action that will act as a cronjob (every 12 hours in this example) which will install all the dependencies on the VM, runs the script and then pushes back the changes.
Example workflow for npm users:
# .github/workflows/screenshots.yaml
name: Generate screenshots
on:
schedule:
- cron: '0 */12 * * *'
jobs:
cron:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16
- name: npm install
run: npm i
- name: make screenshots
run: npm run screenshots
- name: push to repository
run: |
git config --global user.name 'Your Name'
git config --global user.email 'username@users.noreply.github.com'
git pull origin main
git commit -am "Automated screenshots"
git push
And also for those who use yarn as their package manager of choice:
# .github/workflows/screenshots.yaml
name: Generate screenshots
on:
schedule:
- cron: '0 */12 * * *'
jobs:
cron:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: DerYeger/yarn-setup-action@master
with:
node-version: 16
- name: yarn install
run: yarn
- name: make screenshots
run: yarn run screenshots
- name: push to repository
run: |
git config --global user.name 'Your Name'
git config --global user.email 'username@users.noreply.github.com'
git pull origin main
git commit -am "Automated screenshots"
git push
Creating The Link Preview Component
Even though the focus is not on the frontend part of this demo, for the sake of this example let’s see how an implementation of a Link Preview component would look like.
The component is going to receive 2 props: the link to the page that we want to redirect if clicked and the path to the image.
The component is built in a Next.js app, that’s why the Head
component is being used for preloading the image. If you are using React, you can use React-Helmet. Additionally, I’ve used the Hover-Card Primitive from Radix-UI.
// components/LinkPreview/index.js
const LinkPreview = ({ children, link, imageURN }) => {
return (
<>
<Head>
<link rel="preload" as="image" href={imageURN} />
</Head>
<HoverCard.Root>
<HoverCard.Trigger asChild>
<a href={link} target="_blank">
{children}
</a>
</HoverCard.Trigger>
<HoverCard.Content side="top" sideOffset={6}>
<img src={imageURN} />
</HoverCard.Content>
</HoverCard.Root>
</>
)
}
Conclusion
If I take a look at all the ideas presented beforehand, I can certainly say that this solution requires a great deal of time to implement and test, but craftsmanship still matters.
A good design of this solution would be to use an API endpoint that takes the screenshot and then caches the response. However, in my opinion a great design would never sacrifice an user, which may think “Is this broken?” while waiting for so long to receive an image.
To sum up everything, automating this process of generating images is clearly not an easy task, but it has a high reward of instantly loading the screenshots without any image flickering for any user at any given time.