@MaximeHeckel

Generate screenshots of your code with a serverless function

June 9, 2020 / 10 min read

Last Updated: June 9, 2020

I was recently looking for ways to automate sharing code snippets, I thought that generating these code snippets images by calling a serverless function could be a pretty cool use case to apply some of the serverless concepts and tricks I've learned the past few months. My aim here was to be able to send a file or the string of a code snippet to an endopoint that would call a function and get back the base64 string representing the screenshot of that same code snippet. I could then put that base 64 string inside a png file and get an image. Sounds awesome right? Well, in this post I'll describe how I built this!

The plan

I've used carbon.now.sh quite a bit in the past, and I noticed that the code snippet and the settings I set on the website are automatically added as query parameters to the URL.

E.g. you can navigate to https://carbon.now.sh/?code=foobar for example and see the string "foobar" present in the code snippet generated.

Thus to automate the process of generating a code source image from this website, I needed to do the following:

  1. ArrowAn icon representing an arrow
    Call the cloud function: via a POST request and pass either a file or a base64 string representing the code that I wanted the screenshot of. I could additionally add some extra query parameters to set up the background, the drop shadow, or any Carbon option.
  2. ArrowAn icon representing an arrow
    Generate the Carbon URL: to put it simply here, decode the base64 or get the file content from the payload of the incoming request, parse the other query parameters and create the equivalent carbon.now.sh URL.
  3. ArrowAn icon representing an arrow
    Take the screenshot: use a chrome headless browser to navigate to the generated URL and take the screenshot.
  4. ArrowAn icon representing an arrow
    Send back the screenshot as a response to the request.

Foundational work: sending the data and generating the URL

The first step involved figuring out what kind of request I wanted to handle and I settled for the following patterns:

  • ArrowAn icon representing an arrow
    Sending a file over POST curl -X POST -F data=@./path/to/file https://my-server-less-function.com/api/carbon
  • ArrowAn icon representing an arrow
    Sending a string over POST curl -X POST -d "data=Y29uc29sZS5sb2coImhlbGxvIHdvcmxkIik=" https://my-server-less-function.com/api/carbon

This way I could either send a whole file or a string to the endpoint, and the cloud function could handle both cases. For this part, I used formidable which provided an easy way to handle file upload for my serverless function.

Once the data was received by the function, it needed to be "translate" to a valid carbon URL. I wrote the following function getCarbonUrl to take care of that:

Implementation of getCarbonUrl

1
const mapOptionstoCarbonQueryParams = {
2
backgroundColor: 'bg',
3
dropShadow: 'ds',
4
dropShadowBlur: 'dsblur',
5
dropShadowOffsetY: 'dsyoff',
6
exportSize: 'es',
7
fontFamily: 'fm',
8
fontSize: 'fs',
9
language: 'l',
10
lineHeight: 'lh',
11
lineNumber: 'ln',
12
paddingHorizontal: 'ph',
13
paddingVertical: 'pv',
14
theme: 't',
15
squaredImage: 'si',
16
widthAdjustment: 'wa',
17
windowControl: 'wc',
18
watermark: 'wm',
19
windowTheme: 'wt',
20
};
21
22
const BASE_URL = 'https://carbon.now.sh';
23
24
const defaultQueryParams = {
25
bg: '#FFFFFF',
26
ds: false,
27
dsblur: '50px',
28
dsyoff: '20px',
29
es: '2x',
30
fm: 'Fira Code',
31
fs: '18px',
32
l: 'auto',
33
lh: '110%',
34
ln: false,
35
pv: '0',
36
ph: '0',
37
t: 'material',
38
si: false,
39
wa: true,
40
wc: true,
41
wt: 'none',
42
wm: false,
43
};
44
45
const toCarbonQueryParam = (options) => {
46
const newObj = Object.keys(options).reduce((acc, curr) => {
47
/**
48
* Go through the options and map them with their corresponding
49
* carbon query param key.
50
*/
51
const carbonConfigKey = mapOptionstoCarbonQueryParams[curr];
52
if (!carbonConfigKey) {
53
return acc;
54
}
55
56
/**
57
* Assign the value of the option to the corresponding
58
* carbon query param key
59
*/
60
return {
61
...acc,
62
[carbonConfigKey]: options[curr],
63
};
64
}, {});
65
66
return newObj;
67
};
68
69
export const getCarbonURL = (source, options) => {
70
/**
71
* Merge the default query params with the ones that we got
72
* from the options object.
73
*/
74
const carbonQueryParams = {
75
...defaultQueryParams,
76
...toCarbonQueryParam(options),
77
};
78
79
/**
80
* Make the code string url safe
81
*/
82
const code = encodeURIComponent(source);
83
84
/**
85
* Stringify the code string and the carbon query params object to get the proper
86
* query string to pass
87
*/
88
const queryString = qs.stringify({ code, ...carbonQueryParams });
89
90
/**
91
* Return the concatenation of the base url and the query string
92
*/
93
return `${BASE_URL}?${queryString}`;
94
};

This function takes care of:

  • ArrowAn icon representing an arrow
    making the "code string" URL safe using encodeURIComponent to encode any special characters of the string
  • ArrowAn icon representing an arrow
    detecting the language: for this I could either look for any language query param, or fall back to auto which and let carbon figure out the language.
  • ArrowAn icon representing an arrow
    taking the rest of the query string and append them to the URL

Thanks to this, I was able to get a valid Carbon URL šŸŽ‰. Now to automate the rest, I would need to paste the URL in a browser which would give the corresponding image of it and take a screenshot. This is what the next part is about.

Running a headless Chrome in a serverless function

This step is the core and most interesting part of this implementation. I was honestly pretty mind blown to learn that it is possible to run a headless chrome browser in a serverless function to begin with. For this, I used chrome-aws-lambda which despite its name or what's specified in the README of the project, seems to work really well on any serverless provider (in the next part you'll see that I used Vercel to deploy my function, and I was able to get this package running on it without any problem). This step also involves using puppeteer-core to start the browser and take the screenshot:

Use chrome-aws-lambda and puppeteer-core to take a screenshot of a webpage

1
import chrome from 'chrome-aws-lambda';
2
import puppeteer from 'puppeteer-core';
3
4
const isDev = process.env.NODE_ENV === 'development';
5
6
/**
7
* In order to have the function working in both windows and macOS
8
* we need to specify the respecive path of the chrome executable for
9
* both cases.
10
*/
11
const exePath =
12
process.platform === 'win32'
13
? 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'
14
: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
15
16
export const getOptions = async (isDev) => {
17
/**
18
* If used in a dev environment, i.e. locally, use one of the local
19
* executable path
20
*/
21
if (isDev) {
22
return {
23
args: [],
24
executablePath: exePath,
25
headless: true,
26
};
27
}
28
/**
29
* Else, use the path of chrome-aws-lambda and its args
30
*/
31
return {
32
args: chrome.args,
33
executablePath: await chrome.executablePath,
34
headless: chrome.headless,
35
};
36
};
37
38
export const getScreenshot = async (url) => {
39
const options = await getOptions(isDev);
40
const browser = await puppeteer.launch(options);
41
const page = await browser.newPage();
42
43
/**
44
* Here we set the viewport manually to a big resolution
45
* to ensure the target,i.e. our code snippet image is visible
46
*/
47
await page.setViewport({
48
width: 2560,
49
height: 1080,
50
deviceScaleFactor: 2,
51
});
52
53
/**
54
* Navigate to the url generated by getCarbonUrl
55
*/
56
await page.goto(url, { waitUntil: 'load' });
57
58
const exportContainer = await page.waitForSelector('#export-container');
59
const elementBounds = await exportContainer.boundingBox();
60
61
if (!elementBounds)
62
throw new Error('Cannot get export container bounding box');
63
64
const buffer = await exportContainer.screenshot({
65
encoding: 'binary',
66
clip: {
67
...elementBounds,
68
/**
69
* Little hack to avoid black borders:
70
* https://github.com/mixn/carbon-now-cli/issues/9#issuecomment-414334708
71
*/
72
x: Math.round(elementBounds.x),
73
height: Math.round(elementBounds.height) - 1,
74
},
75
});
76
77
/**
78
* Return the buffer representing the screenshot
79
*/
80
return buffer;
81
};

Let's dive in the different steps that are featured in the code snippet above:

  1. ArrowAn icon representing an arrow
    Get the different options for puppeteer (we get the proper executable paths based on the environment)
  2. ArrowAn icon representing an arrow
    Start the headless chrome browser
  3. ArrowAn icon representing an arrow
    Set the viewport. I set it to something big to make sure that the target is contained within the browser "window".
  4. ArrowAn icon representing an arrow
    Navigate to the URL we generated in the previous step
  5. ArrowAn icon representing an arrow
    Look for an HTML element with the id export-container, this is the div that contains our image.
  6. ArrowAn icon representing an arrow
    Get the boundingBox of the element (see documentation for bounding box here) which gave me the coordinates and the width/height of the target element.
  7. ArrowAn icon representing an arrow
    Pass the boundingBox fields as options of the screenshot function and take the screenshot. This eventually returns a binary buffer that can then be returned back as is, or converted to base64 string for instance.
Screenshot showcasing the export-container div highlighted in Chrome and Chrome Dev Tools

Deploying on Vercel with Now

Now that the function was built, it was deployment time šŸš€! I chose to give Vercel a try to test and deploy this serverless function on their service. However, there was a couple of things I needed to do first:

  • ArrowAn icon representing an arrow
    Put all my code in an api folder
  • ArrowAn icon representing an arrow
    Create a file with the main request handler function as default export. I called my file carbonara.ts hence users wanting to call this cloud function would have to call the /api/carbonara endpoint.
  • ArrowAn icon representing an arrow
    Put all the rest of the code in a _lib folder to prevent any exported functions to be listed as an endpoint.

Then, using the Vercel CLI I could both:

  • ArrowAn icon representing an arrow
    Run my function locally using vercel dev
  • ArrowAn icon representing an arrow
    Deploy my function to prod using vercel --prod

Try it out!

You can try this serverless function using the following curl command:

Sample curl command to call the serverless function

1
curl -d "data=Y29uc29sZS5sb2coImhlbGxvIHdvcmxkIik=" -X POST https://carbonara-nu.now.sh/api/carbonara

If you want to deploy it on your own Vercel account, simply click the button bellow and follow the steps:

Otherwise, you can find all the code featured in this post in this Github repository.

What will I do with this?

After reading all this you might be asking yourself: "But Maxime, what are you going to do with this? And why did you put this in a serverless function to begin with?". Here's a list of the few use cases I might have for this function:

  • ArrowAn icon representing an arrow
    To generate images for my meta tags for some articles or snippets (I already do this now šŸ‘‰ Tweet from @MaximeHeckel
  • ArrowAn icon representing an arrow
    To be able to generate carbon images from the CLI and share them with my team at work or other developers quickly
  • ArrowAn icon representing an arrow
    Enable a "screenshot" option for the code snippets in my blog posts so my readers could easily download code screenshots.
  • ArrowAn icon representing an arrow
    Many other ideas that I'm still working on right now!

But, regardless of its usefulness or the number of use cases I could find for this serverless function, the most important is that I had a lot of fun building this and that I learned quite a few things. I'm now definitely sold on serverless and can't wait to come up with new ideas.

Liked this article? Share it with a friend on Twitter or support me to take on more ambitious projects to write about. Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I'll do my best to get back to you.

Have a wonderful day.

ā€“ Maxime

Programmatic Carbon images generation from a simple API