Running Puppeteer on AWS Lambda in a Docker container

The aim of this guide is to provide a working solution to generating a PDF version of a webpage using Puppeteer running in a Docker container as a Lambda function. The Docker container approach is used to bypass the 50MB Lambda code size limit. The other option is to use something like chrome-aws-lambda.

We’ll start with the Dockerfile, which assumes Lambda function with a node.js v16 engine called index.js with a named handler export:

FROM public.ecr.aws/lambda/nodejs:16

# Required for puppeteer to run
RUN yum install -y amazon-linux-extras
RUN amazon-linux-extras install epel -y

# Chromium dependencies
RUN yum install -y \
  GConf2.x86_64 \
  alsa-lib.x86_64 \
  atk.x86_64 \
  cups-libs.x86_64 \
  gtk3.x86_64 \
  ipa-gothic-fonts \
  libXScrnSaver.x86_64 \
  libXcomposite.x86_64 \
  libXcursor.x86_64 \
  libXdamage.x86_64 \
  libXext.x86_64 \
  libXi.x86_64 \
  libXrandr.x86_64 \
  libXtst.x86_64 \
  pango.x86_64 \
  xorg-x11-fonts-100dpi \
  xorg-x11-fonts-75dpi \
  xorg-x11-fonts-Type1 \
  xorg-x11-fonts-cyrillic \
  xorg-x11-fonts-misc \
  xorg-x11-utils

RUN yum update -y nss

# Chromium needs to be installed as a system dependency, not via npm; otherwise there will be an error about missing libatk-1.0
RUN yum install -y chromium

COPY index.js package.json package-lock.json ${LAMBDA_TASK_ROOT}

RUN npm ci --omit=dev

CMD [ "index.handler" ]

The above Dockerfile assures all required dependencies are in place. The next step is to setup the Puppeteer’s launch. Here is the relevant snippet from the Lambda function code:

import puppeteer from 'puppeteer'

const viewportOptions = {
  args: [
    // Flags for running in Docker on AWS Lambda
    // https://www.howtogeek.com/devops/how-to-run-puppeteer-and-headless-chrome-in-a-docker-container
    // https://github.com/alixaxel/chrome-aws-lambda/blob/f9d5a9ff0282ef8e172a29d6d077efc468ca3c76/source/index.ts#L95-L118
    // https://github.com/Sparticuz/chrome-aws-lambda/blob/master/source/index.ts#L95-L123
    '--allow-running-insecure-content',
    '--autoplay-policy=user-gesture-required',
    '--disable-background-timer-throttling',
    '--disable-component-update',
    '--disable-dev-shm-usage',
    '--disable-domain-reliability',
    '--disable-features=AudioServiceOutOfProcess,IsolateOrigins,site-per-process',
    '--disable-ipc-flooding-protection',
    '--disable-print-preview',
    '--disable-setuid-sandbox',
    '--disable-site-isolation-trials',
    '--disable-speech-api',
    '--disable-web-security',
    '--disk-cache-size=33554432',
    '--enable-features=SharedArrayBuffer',
    '--hide-scrollbars',
    '--ignore-gpu-blocklist',
    '--in-process-gpu',
    '--mute-audio',
    '--no-default-browser-check',
    '--no-first-run',
    '--no-pings',
    '--no-sandbox',
    '--no-zygote',
    '--single-process',
    '--use-angle=swiftshader',
    '--use-gl=swiftshader',
    '--window-size=1920,1080',
  ],
  defaultViewport: null,
  headless: true,
}

const browser = await puppeteer.launch(viewportOptions)

try {
  const page = await browser.newPage()

  const url = 'https://...'

  await page.goto(url, { waitUntil: ['domcontentloaded', 'networkidle0'] })
  await page.emulateMediaType('print')

  const pdf = await page.pdf({})
} catch (error) {
  ...
}

Testing useNavigate() / navigate() from react-router v6

Testing navigate() is slightly more problematic with the latest v6 (as of writing this post) react-router than just asserting on history.push() as it was the case in the previous versions. Let’s say we have this ButtonHome component:

import { useNavigate } from 'react-router-dom'

const ButtonHome = () => {
  const navigate = useNavigate()

  const onClick = () => navigate('/home')

  return (
    <button onClick={onClick}>
      Home
    </button>
  )
}

I would write a test for this component using the react-testing-library in the following way:

import * as router from 'react-router'
import { render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

import ButtonHome from './ButtonHome'

describe('ButtonHome', () => {
  const ui = userEvent.setup()
  const navigate = jest.fn()

  beforeEach(() => {
    jest.spyOn(router, 'useNavigate').mockImplementation(() => navigate)
  })

  it('renders the button and navigates to /home upon click', async () => {
    render(withRouter(<ButtonHome />))
    
    await ui.click(screen.queryByText('Home'))

    expect(navigate).toHaveBeenCalledWith('/home')
  })
})

The relevant bits just for testing the router are as follows:

import * as router from 'react-router'

const navigate = jest.fn()

beforeEach(() => {
  jest.spyOn(router, 'useNavigate').mockImplementation(() => navigate)
})

it('...', () => {
  expect(navigate).toHaveBeenCalledWith('/path')
})

The test also requires the following withRouter() helper, which I have in jest.setup.js:

import { Route, Router, Routes } from 'react-router-dom'
import { createBrowserHistory } from 'history'

const history = createBrowserHistory()

const withRouter = (children, opts = {}) => {
  const { path, route } = opts

  if (path) {
    history.push(path)
  }

  return (
    <Router location={history.location} navigator={history}>
      <Routes>
        <Route
          path={route || path || '/'}
          element={children}
        />
      </Routes>
    </Router>
  )
}

global.withRouter = withRouter

Setting up mocha with sinon and chai

I was unable to quickly find a solution for this, so here’s a little guide on how to set it up together in a proper way.

First, install the libraries:

npm install mocha --save-dev
npm install sinon --save-dev
npm install chai --save-dev

I come from the Ruby world, so I expect to have a spec command, spec_helper.js file and specs living inside spec/ directory (with a nested structure).

Inside package.json file define the spec command:

"scripts" : {
  "spec": "mocha --opts spec/mocha.opts"
}

We will be using BDD style (expect().to()) of chai. Inside spec/mocha.opts add:

--recursive **/*_spec.js
--require spec/spec_helper.js
--ui bdd

Create spec/spec_helper.js, which will require chai and sinon and we will require `spec_helper.js` inside all specs (similarly to how RSpec in Ruby world works).

const sinon = require('sinon')
const expect = require('chai').expect

global.sinon = sinon
global.expect = expect

And now create your spec file (spec/module_spec.js). You should not be required to include any libraries there. Now you can run your specs:

npm run spec

References:

Steve Yegge live

After yesterday’s best read of the month here comes the best show of the day. Which is Steve Yegge, live, talking about branding during 2007 OSCON. Without any slides. 25 minutes.

He also reveals that in his opinion Javascript2 will be the Next Big Language (NBL). I was kind of suspecting this would be it. Coincidently, lately I’ve discovered (what a big word) jQuery. And let me tell you, I’ve never seen any other piece of code, which is so concise and does so many things with so much style. Prototype, dojoToolkit and all other js libraries simply look pale in comparison. I’m hooked on jQuery and use it for every new project, while trying to implement it in my old ones too.