Intro

Back in June Sam Saccone had a post about Chrome Headless that seemingly was loved by many with a plethora of likes and re-tweets.

With Chrome Headless there are a multitude of opportunities for testing but my goals were a little different. I wanted to create a way to capture screenshots and generate PDF’s.

My main use case was to create a way for easier PDF exports. Why not create a rest service that takes a URL and kicks out a PDF? This means I can use whatever UI framework I choose to create my exports.

Building Chrome headless

Building Chrome is not short process. Make sure that you read all the steps before you begin so you don’t have to start over.

  1. Create an Ubuntu EC2 on AWS There are are a few changes we need to make to the standard Ubuntu image before it is usable for building Chrome.

  2. Select the standard Ubuntu image. EC2

  3. Select a large instance type. I usually go for c3.4xlarge. You need a machine with at least 16gb of memory. EC2 Type

  4. The standard storage configuration is not acceptable and needs to be adjusted. Storage

  5. I adjust the main storage to have 250gb. This is to make sure that there is enough space for any dependencies. EditedStorage

  6. SSH to the instance ssh -i ~/.ssh/{PemKey}.pem ubuntu@{MachineDNS}

  7. Switch user sudo su

  8. Install git apt-get install -y git

  9. Clone the Google Depot tools git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git

  10. Add Depot Tools to your bash profile echo "export PATH=$PATH:/home/ubuntu/depot_tools" >> ~/.bash_profile

  11. Source the bash profile source ~/.bash_profile

  12. Make a Chromium directory and enter it. mkdir Chromium && cd Chromium

  13. Fetch Chromium source with no history. This will keep the download size down. fetch --no-history chromium

  14. Enter the src directory cd src

  15. Build the Chromium dependencies ./build/install-build-deps.sh --no-prompt.

  16. You will be prompted to accept a EULA for fonts. Accept the agreement.

  17. Create an output directory mkdir -p out/Headless

  18. Create a build arguments file

  19. echo 'import("//build/args/headless.gn")' > out/Headless/args.gn

  20. echo 'is_debug = false' >> out/Headless/args.gn

  21. Generate Ninja Build files gn gen out/Headless

  22. Kick off the build ninja -C out/Headless headless_shell

  23. Verify the build was successful. out/Headless/headless_shell http://goat.com

  24. Tar the build tar -zcvf ChromeHeadless.tar.gz out/Headless

Copy the tarball to your local machine.

  1. Use SCP to copy the tarball scp -i ~/.ssh/{PemKey}.pem ubuntu@{MachineDNS}:/home/ubuntu/src/ChromiumHeadless.tar.gz ~/Desktop/ChromiumHeadless.tar.gz

Node

I am a huge fan of Typescript as such my Node server is written in Typescript.

let express = require('express');
let fs = require('fs');
let Chrome = require('chrome-remote-interface');
let spawn = require('child_process').spawn;
let PDFDocument = require('pdfkit');
// The second argument when starting node is the location of the headless_shell binary.
if (process.argv[ 2 ] === undefined) {
  throw Error('No headless binary path provided.');
}

// We need to spawn Chrome headless with some parameters, one of which is the debug port.
// I am hard coding window size because I want the screenshot to be a specific size.
// Note: 1280x1696 is the default pixel resolution for a Letter sheet of paper.
spawn(process.argv[ 2 ], ['--no-sandbox', '--remote-debugging-port=9222','--window-size=1280x1696' ]);

let app = express();

// Just to demonstrate the app working fetch on root of the app causes the PDF to be generated.
app.get('/', (req, res)=> {
  Chrome.New(function () {
    Chrome(chromeInstance => {
      chromeInstance.Page.loadEventFired(takeScreenshot.bind(null, chromeInstance, res, Date.now()));
      chromeInstance.Page.enable();
      chromeInstance.once('ready', ()=> {
        chromeInstance.Page.navigate({url: 'https://addyosmani.com/'});
      })
    });
  });
});


function takeScreenshot(instance, response, startTime) {
  let screenshot = function (v) {
    let filename = `screenshot-${Date.now()}`;
    fs.writeFileSync(filename + '.png', v.data, 'base64');

    console.log(`Image saved as ${filename}.png`);

    let imageEnd = Date.now();

    console.log('image success in: ' + (+imageEnd - +startTime) + "ms");
    // Using PDFKit to create the actual PDF document
    let doc = new PDFDocument({
      margin:0,
      size:'letter'
    });
    // This width is in points not pixels.
    doc.image(filename + '.png', 0,0, {width:612});
    // I wanted the file response to trigger a download rather than loading in the page.
    response.setHeader('Content-Type', 'application/pdf');
    response.setHeader('Content-Disposition', 'attachment; filename=' + filename + '.pdf');
    // Pipe the document into the response and close the stream when it's completed.
    doc.pipe(response);

    let docEnd = Date.now();
    doc.end();

    let endTime = Date.now();
    console.log('pdf success in: ' + (+docEnd - +startTime) + "ms");
    console.log('success in: ' + (+endTime - +startTime) + "ms");
    // Kill the tab we are using to save on memory.
    instance.close();
  };
  // This is the magic here.
  instance.Page.captureScreenshot().then(screenshot);
}

app.listen(3000, function () {
  Chrome.Version().then(version => {
    console.log(version)
  });
  console.log('Export app running on 3000!');
});

Docker

Part of being able to run our export service headless is actually deploying it headlessly. To do this we are going to use Docker. Below is the Dockerfile used to build a docker machine with all the dependencies necessary to run Chromium and Node.

FROM ubuntu:16.04

# Install.
RUN \
    apt-get update && \
    apt-get install sudo && \
    sed -i 's/# \(.*multiverse$\)/\1/g' /etc/apt/sources.list && \
    apt-get update && \
    apt-get -y upgrade && \
    apt-get install -y build-essential && \
    apt-get install -y software-properties-common && \
    apt-get install -y byobu curl git htop man unzip vim wget && \
    rm -rf /var/lib/apt/lists/* && \
    curl -sL https://deb.nodesource.com/setup_6.x | sudo -E bash - && \
    sudo apt-get install -y nodejs && \
    sudo apt-get install -y libnss3 && \
    sudo apt-get install -y libgtk2.0-0 libgdk-pixbuf2.0-0 libfontconfig1 libxrender1 libx11-6 libglib2.0-0 libxft2 libfreetype6 libc6 zlib1g libpng12-0 libstdc++6-4.8-dbg-arm64-cross libgcc1

COPY ./node_modules /root/export-app/node_modules
ADD ./app.js /root/export-app/app.js
ADD ./package.json /root/export-app/package.json
ADD ./ChromiumHeadless.tar.gz /root/export-app

WORKDIR /root/export-app

EXPOSE 8080 3000

CMD ["node", "app.js", "/root/export-app/chromium/src/out/Debug/headless_shell"]

Thanks

None of this could have been done without amazing help from a large group of people: Sam Saccone, Andrea Cardaci, Sami Kyöstilä, Paul Lewis, Amin Moshgabadi, Chris Judd,Eric Seckler, PatrickJS