Using AWS Lambdas + headless Chrome to Generate PDF files from HTML
Published: 2020-12-03 02:26:28

This post assumes that you have basic knowledge of the Serverless framework and AWS Lambda, have created an AWS account with a lambda function initialized. This article will focus on going over the code used to generate a PDF file using user input received from a client and an HTML template, then store it in an AWS s3 bucket.

Make sure that you have added your [AWS credentials to serverless](https://www.serverless.com/framework/docs/providers/aws/guide/credentials/), you can do so from the CLI as so:

```bash
export AWS_ACCESS_KEY_ID=<your-key-here>
export AWS_SECRET_ACCESS_KEY=<your-secret-key-here>
# AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are now available for serverless to use
serverless deploy

# 'export' command is valid only for unix shells. In Windows - use 'set' instead of 'export'
```

First things first, let's create a directory:

`mkdir lambdas && cd lambdas`

<b>Note:</b>
I have this trusty alias in my ~/.zshrc file that I use to create directories and cd into them:
```bash
function mkcd {
    local target=$1
    mkdir -p "$target"
    cd $target
}
```

Let's install our dependencies:
`npm install aws-sdk chrome-aws-lambda puppeteer-core`

- `aws-sdk` will help use connect to AWS and make use of their services such as lambda and s3!
- `chrome-aws-lambda` & `puppeteer-core` will allow us to spin up a headless version of chrome. We will use this instance to create a blank page, add the content, and store it as a PDF

** Make sure you have the serverless framework installed globally ** 
(`npm install -g serverless`)


Once inside our directory or your location of choice, we can go ahead and leverage the serverless template scaffolding to create a new `handler.js` using the nodejs blueprint.
`serverless create --template aws-nodejs --name generatePdf`

This will create 3 files for us:
- `handler.js`
- `serverless.yml`
- `.gitignore`

The first thing I did was to go ahead and clean up all of the contents in the `serverless.yml` file and only left what was necessary for us to be able to perform the desired action.

After some cleanup, my `serverless.yml` file looks like this:
```yml
service: pdf

frameworkVersion: "2"

provider:
  name: aws
  runtime: nodejs12.x
  region: us-east-1

functions:
  handler:
    handler: handler.handler # 👈 this access the handler
    memorySize: 1600 # here we tell AWS how much memory to allocate
    timeout: 30
    events:
      - http:
          path: users/create # 👈 this defines the http path
          method: get
          cors: true

package:
  exclude:
    - node_modules/puppeteer/.local-chromium/**
```

At the time of writing this, the `handler.js` file should initially look like this:

```js
'use strict';

module.exports.hello = async event => {
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'Go Serverless v1.0! Your function executed successfully!',
        input: event,
      },
      null,
      2
    ),
  };

  // Use this code if you don't use the http event with the LAMBDA-PROXY integration
  // return { message: 'Go Serverless v1.0! Your function executed successfully!', event };
};

```

At this point, none of this is relevant to our goal. We will go ahead and clear everything up, install dependencies and get to the nitty gritty!


If you recall, in our config file, we said that the path to our module would be called handler and that lived within handler.js

```js
functions:
  handler:
    handler: handler.handler // 👈 this
    memorySize: 1600
    timeout: 30
```

It really doesn't matter what you call your handler, just make sure that the name is the same in both your config file and your exported module. 

```js
module.exports.handler = async event => {
   // .. cleaned up content and changed name of
   // exported module from hello to handler
};
```

Now, let's import and initialize our S3 instance from the `aws-sdk` package we installed earlier.


```js
"use strict"

const { S3 } = require("aws-sdk");

const s3 = new S3(); // 👈 initialize our instance

module.exports.handler = async event => {
   // .. cleaned up content and changed name of
   // exported module from hello to handler
};
```

Let's also add the rest of the utilities from `chrome-aws-lamda` and add some initial boilerplate code.

```js
"use strict"

const { S3 } = require("aws-sdk");
const { puppeteer, args, defaultViewport, executablePath } = require("chrome-aws-lambda");

const s3 = new S3(); // 👈 initialize our instance

module.exports.handler = async (event, ctx, cb) => {
  let result = null;
  let browser = null;

  const date = new Date().toISOString(); // we will use this
                                         // to create filename

  const filename = `pdf-${date}` // you can call this whatever you want
                                 // but make it unique or else the file
                                 // will be replaced

  const pdfPath = `/tmp/${filename}.pdf`
  // I will pause here to further talk about tmp files
};
```

I want to make a quick note about `/tmp/` files in case you are not familiar with them. As the shorthand notation might infer, it refers to a temporary file. These files are typically created by an application to store some form of temporary data.

In a nutshell, whenever we run our function it will generate this temporary directory and once the execution of the function is done, it will discard it. This provides the perfect environment needed to create a pdf file and store it in S3(more permanent storage).

Back from our short break and let's start writing some funky logic.

```js
module.exports.handler = async event => {
  let result = null;
  let browser = null;

  const date = new Date().toISOString(); filename

  const filename = `pdf-${date}` 

  const pdfPath = `/tmp/${filename}.pdf`

  try {
    console.log("Establishing connection...");
    // Initialize and launched puppeteer
    browser = await puppeteer.launch({
      args,
      defaultViewport,
      executablePath: await executablePath,
      headless: true,  // 👈Very important, remember we want to run headless chrome
      ignoreHTTPSErrors: true,
    });

    console.log("Opening new page...");
    // 👇 create a new headless chrome pag
    const page = await browser.newPage();

    console.log("Generating PDF file from HTML template...");

     // 👇 Ignore this line for right now
    await page.setContent('<h1>Hello world!</h1>', { waitUntil: "networkidle2" });
    
    // 👇 this tells puppeteer to save the webpage as a pdf file
    await page.pdf({ format: "Letter", path: pdfPath });

    const params = {
      Key: pdfPath,
      Body: fs.createReadStream(pdfPath),
      Bucket: "<yourS3Bucket>",
      ContentType: "application/pdf",
    };

    console.log("Uploading PDF...");
    
    // 👇 Pretty self explanatory but this is what uploads
    // and store our PDF or file to S3
    await s3
      .upload(params, async (err, res) => {
        if (err) {
          console.log(err);
          throw new Error(err);
        }
        console.log("done");
        console.log(res);
        return cb(null, res);
      })
      .promise();

    result = await page.title();
  } catch (error) {
    return cb(error);
  } finally {
    if (browser !== null) {
      console.log("Closing browser...");
      await browser.close();
    }
  }
  return cb(null, result);
};

```

So! This is pretty much it for the handler itself, but we are still missing something very important! We are missing our HTML template, so go ahead and create a new file inside your directory called `template.js` (or whatever you want to call it).

Wait! `.js` file? But I thought we were creating an HTML template! 
And yes, you are correct we are creating an HTML template, BUT because we want to be able to add dynamic fields to that template we will return the HTML content from a function. This way we can add variables and interpolate them into our HTML. JS FTW!

From your root directory, run `touch template.js` and enter the following contents to give you an example of how it would be done:

```js
module.exports.template = ({ someVariable }) => {
  const today = new Date();
  return `
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8" />
        <title>PDF Result Template</title>
        <style>
          .container {
            background-color: rebbeccapurble
          }
        </style>
      </head>
      <body>
          <div class="container">
            Hello ${someVariable}!
            Today's date is: ${today}
          </div>
      </body>
    </html>
  `;
};

```

Up until right now, our serverless function should be able to generate a mostly blank PDF doc with Hello World in it. That isn't dynamic enough though.

To fix this we can import our `template.js` module into our function and call it from the page set content function.

Inside our handler, we had a comment that said to ignore the below-called function. Well, let's give it some attention now.

In `await page.setContent('<h1>Hello world!</h1>', { waitUntil: "networkidle2" })`

Replace the first string argument with the important function and pass it some data.

```js
// ... other imports
const { template } = require("./template");
                  // destructure 👇 data from event argument
module.exports.handler = async({ data }, ctx, cb) => {
     // ...
    //
    await page.setContent(template({ ...data }), { waitUntil: "networkidle2" });
   // ...
};

```

`page.setContent` could be considered the main bread and butter since this is what merges our blank page and our template to render the desired outcome.

We can test if this works locally by running the following command:
`serverless invoke local --function functionName --data '{"data": "Alejandro"}'`

Finally, you can run `serverless deploy`!

Thanks for reading, any feedback and/or improvements are more than welcome :)