Generate PDFs from HTML via Puppeteer on AWS Lambda + API Gateway
Tags: lambda, puppeteer, pdf, aws
Published: 2022-04-25 01:45:00

_“Evil cannot create anything new, they can only corrupt and ruin what good forces have invented or made.”_ - JRR Tolkien.

# Preface

Would it be great to have the functionality that would enable you to generate PDF files using HTML && CSS capabilities without the need to rely on overly complex drivers that are dependent on a whole bunch of C [libraries](https://wkhtmltopdf.org/libwkhtmltox/)?

While also [supporting](https://github.com/wkhtmltopdf/wkhtmltopdf/issues/3661) all the latest features of HTML5 && CSS3?

Well, we have great news. There is a framework called [Puppeteer](https://github.com/puppeteer/puppeteer) that uses relatively new Chrome [feature](https://developers.google.com/web/updates/2017/04/headless-chrome) and makes it accessible though a NodeJS based API.

Essentially what **Puppeteer** does, is: Launches a Chromium browser instance in a headless mode ( not actually opening it ), and allows us to manipulate the browser via set of API command to parse website, retrieve images and generate PDF as if you were actually opening an HTML file in the latest browser version, etc..

While we can create a running [Docker](https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#running-puppeteer-in-docker) Puppeteer instance and deploy that on ECS or Heroku. The creation of stable && optimized image can be quite challenging...

Having a running instance in AWS Lambda _IHMO_ in contrast would be much simpler in terms of development speed, debug and monitoring. Besides, serverless, is a nice concept for POC ( you pay for what you use )

# Repo -> End Result

You can see the complete working example in this [repo](https://github.com/zahaar/generate-pdf-lambda)

{% github https://github.com/zahaar/generate-pdf-lambda %}

### Requirements and Prerequisites

- [AWS SAM](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html)
- [Docker](https://docs.docker.com/get-docker/)
- [AWS Console Account && API Keys](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html)

### 1. SetUp local AWS SAM Template with Chrome Lambda Layer

In this step the local SAM execution setUp will be complete. Once this is done, we will have a strong reference point.

> The end version of this step can be fetched from [`1_local-setup`](https://github.com/zahaar/generate-pdf-lambda/commits/1_local-setup) branch

We can create a basic SAM template by running `sam init` or reference a [guide](https://dev.to/zahaar/snss3api-aws-lambda-opinionated-boilerplate-cookicutter-8f3)

but our end goal should be a sophisticated structure like this

```
├── Makefile
├── VERSION -- for VERSION tracking, helpful for CI
├── envs.json -- to sep envs for local execution ( if necessary )
├── events
│   └── api-gw-event.json -- an example API GW event for local execution
├── src
│   └── app.js -- main source code file
└── template.yaml -- AWS SAM configuration template
```

`app.js` contains simple code that will return the same `event.body` that it receives from example event.

```js
...
...
var response = {
    statusCode: 200,

    body: event.body,
  }

  return response
...
```

while `template.yml` has a resource configuration for API GW Service

```
...
...
  ApiGatewayApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: Staging
    BinaryMediaTypes:
      - application~1pdf  // Note the support for binary pdf media Type
...
```

and the Lambda. As per context of our goal, it's called `PdfFunction`
Take note of the `Layer` being used in this config. By setting `chrome-aws-lambda`, we have essentially ruled out the need to set `package.json` dependencies for `puppeteer` and `chrome` on `Docker` image thar AWS is using on EC2 for Lambdas, as this step can be quite challenging.

```
...
...
  PdfFunction:
    Type: AWS::Serverless::Function
    Description: Invoked by EventBridge scheduled rule
    Properties:
      CodeUri: src/
      Handler: app.handler
      Runtime: nodejs12.x
      Timeout: 15
      MemorySize: 3008

      Layers:
        - !Sub 'arn:aws:lambda:${AWS::Region}:764866452798:layer:chrome-aws-lambda:22'
      Environment:
        Variables:
          EXAMPLE_ENV: 'CHANGE_THIS'
      Events:
        ApiEvent:
          Type: Api
          Properties:
            Path: /pdf
            Method: post
            RestApiId:
              Ref: ApiGatewayApi
...
```

To test that all requirements are met, let's run a local event.

```
make invokation-local
```

The output should be essentially the same as the execution logs in CloudWatch

```
...
...
Mounting /Users/wparker/Dev/scheduled-website-screenshot-app/.aws-sam/build/PdfFunction as /var/task:ro,delegated inside runtime container
START RequestId: e4d7743d-5be2-4735-84c8-9d5160d9a750 Version: $LATEST
...
```

### 2. Configure Puppeteer in Lambda; Supply Template HTML

Next step is to program `app.js` to start `puppeteer`, consume `HTML` from an `API GW` event and return a `base64` encoded response that would be `decoded` on `Response` by `API GW`.

> The end version of this step can be fetched from [`2_generate-pdf`](https://github.com/zahaar/generate-pdf-lambda/tree/2_generate-pdf) branch

We need to change the Lambda handler code to something like this. [`File`](https://github.com/zahaar/generate-pdf-lambda/blob/76f18b4e4973d47416ee06183309b80e34bdf3c9/src/app.js) ( File is too long to displayed here )

Key takeaways are:

1. Browser launch args parameters in this example are set specifically for AWS Lambda compatibility.

```js
...
...
    browser = await chromium.puppeteer.launch({
      args: chromium.args,
      defaultViewport: chromium.defaultViewport,
      executablePath: await chromium.executablePath,
      headless: chromium.headless,
      ignoreHTTPSErrors: true,
    })
```

2. The return format goal was set to mimic A4 document.

```js
...
...
    await page.setViewport({
      width: 1080,
      height: 1600,
      deviceScaleFactor: 1,
      isLandscape: true,
    })
    pdf = await page.pdf({
      format: 'a4',
      margin: {
        top: '0px',
        right: '0px',
        bottom: '0px',
        left: '0px',
      },
    })
...
```

3. The response headers are set for `pdf` file transfer. `isBase64Encoded` flag is set to `true` to inform `API GW` that it needs to decode the file.

```js
...
...
  var response = {
    statusCode: 200,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST',
      'Content-type': 'application/pdf',
      'Content-Disposition': 'attachment; filename="foo.pdf"',
    },
    isBase64Encoded: true,
    body: pdf.toString('base64'),
  }
...
```

To test this code, an `HTML` template is needed. We will use this open-source [one](https://github.com/sparksuite/simple-html-invoice-template) for demonstration.
The document is being sent as body with `'Content-Type: text/html'`

> Please note `'Accept: application/pdf'`, this is important.

The end result of `cUrl` request is in this [file](https://github.com/zahaar/generate-pdf-lambda/blob/76f18b4e4973d47416ee06183309b80e34bdf3c9/cUrl.txt)

To test our result let's start local SAM in local `start-api` mode. ( akin to a server, contrary to one time invokation)

```bash
make api-local
```

Import `cUrl` into `Insomnia`.

#### _**Result**_

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rnjmh22yf4363ml7mo9e.png)
 

### 3. Deploy Lambda + API GW via SAM

Refers to step in [`README.md`](https://github.com/zahaar/generate-pdf-lambda/blob/e11413b680f4c86b45789e938c81402508478900/README.md) on `main` branch

# Tips

##### 1. Insomnia vs Postman

Instead of playing tricks with [Postman PDF Visualization](https://github.com/postmanlabs/postman-app-support/issues/8040). I highly recommend switching to [Insomnia](https://insomnia.rest/)

|                                            Insomnia Visualized Response                                            |                                            Postman Visualization Response                                            |
| :----------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------: |
| ![Insomnia Visualized Response](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xa1ugbrjn96xittax7ro.png) | ![Postman Visualization Response](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/99y0polvmqzuif1pdox8.png) |

##### 2. `Error: Error building docker image: pull access denied for`

{% stackoverflow https://stackoverflow.com/a/65721111/16349329 %}.

##### 3. Consider using latest AWS Lambda feature: [Lambda URL](https://docs.aws.amazon.com/lambda/latest/dg/urls-configuration.html)

##### 4. Be mindful of HTTP Headers, when dealing with API GW. As this can lead to a huge [confusion](https://github.com/twitchtv/twirp/issues/81).