Alex Gourlay

Devicer

Date

Organisation

Personal

Role

Software development

Repository

Devicer

Technologies

Typescript|Node.js|Express|Puppeteer

Overview

Devicer is a RESTful service built using Node.js that dynamically creates screenshots of websites within device frames.

Screenshots produced by Devicer
Screenshots produced by Devicer

The service uses Puppeteer, a headless version of Google Chrome, to capture the screenshot and Sharp, an image processing library for compositing the screenshot with a .png of a chosen device frame.

The available devices are stored in an object, with each device conforming to the Device interface. The imageUri field specifies a path to the associated image asset. A Puppeteer device is also provided, which is used during the screenshot capture process to emulate the viewport of the device.

import { devices as puppeteerDevices } from "puppeteer";
import Device from "../models/Device";

export interface Device {
  id: string;
  imageUri: string;
  puppeteerDevice: PuppeteerDevice;
}

type DevicesById = Record<Device["id"], Device>;

export default devices: DevicesById = {
  "apple-iPhone-11-pro-max": {
    id: "apple-iPhone-11-pro-max",
    imageUri: "assets/Apple iPhone 11 Pro Max Space Grey.png",
    puppeteerDevice: puppeteerDevices["iPhone 11 Pro Max"],
  },
  "apple-iPad-pro-13-landscape": {
    id: "apple-iPad-pro-13-landscape",
    imageUri: "assets/Apple iPad Pro 13 Silver - Landscape.png",
    puppeteerDevice: puppeteerDevices["iPad Pro landscape"],
  },
};

Image Processing

The Devicer class contains all the methods for the image generation and is constructed with a url from which the screenshot should be obtained, as well as a deviceID which is used to specify the device to composite the screenshot with.

export interface DevicerParams {
  url: string;
  deviceID: string;
}

class Devicer {
  device: Device;
  url: string;

  constructor(params: DevicerParams) {
   let device = devices[params.deviceID];

    /* Check if device exists. */
    if (device === undefined) {
      throw new ClientFacingError(
        StatusCode.ClientErrorNotAcceptable,
        `Invalid device ID, valid device IDs include: ${Object.keys(
          devices
        ).slice(0, 6)}...`
      );
    }

    /* Check if url is valid */
    if (!isValidUrl(params.url)) {
      throw new ClientFacingError(
        StatusCode.ClientErrorNotAcceptable,
        `Invalid url "${params.url}" supplied.`
      );
    }

    this.device = device;
    this.url = params.url;
  }

  async generate() {}

  async getUrlScreenshot(url: string, device: Device) {}

  getDeviceImage(uri: string) {}
}

The getUrlScreenshot method uses Puppeteer to open a headless browser and open a new page. The device's puppeteerDevice is then emulated, which sizes the browser's viewport to the dimensions of the device as well as setting other properties such as the user-agent string, device scale factor and touch capability.

The page then goes to the specified url with the option waitUntil set to 'networkidle0'. The waitUntil option allows the stage at which the page is considered fully loaded to be defined. Given that we don't know the content loading strategy of the specified site, it is best to go with the safest option.

async getUrlScreenshot(url: string, device: Device) {
  let browser = null;
  try {
    browser = await puppeteer.launch({ headless: true });
    const page = await browser.newPage();

    /* Emulate device. */
    await page.emulate(device.puppeteerDevice);

    try {
      /* Go to url, wait until network is idle. */
      await page.goto(withHttp(url), { waitUntil: "networkidle0" });
    } catch (error) {
      throw new ClientFacingError(StatusCode.ServerErrorBadGateway, error);
    }
    /* Take screenshot */
    return await page.screenshot();
  } 
}

Retrieving the device image is much simpler and leverages the Sharp constructor to load the .png of the frame as a Sharp instance.

getDeviceImage(uri: string) {
  return sharp(path.join(process.cwd(), uri));
}

Generating is then just a matter of calling the getUrlScreenshot and getDeviceImage methods to retrieve the two images and then using the composite method to combine them. There are many different blend modes that are available, full list available at libvips Blend Mode, each defines what operation should be performed on the overlapping pixels of the images. For this application, dest-over is the applicable mode, whereby the pixels of the screenshot that overlap the non-transparent pixels of the frame are discarded.

async generate() {
  try {
    const deviceImage = this.getDeviceImage(this.device.imageUri);
    const screenShotBuffer = await this.getUrlScreenshot(
      this.url,
      this.device
    );
    /* Underlays the screenshot with the device image. */
    return deviceImage
      .composite([{ input: screenShotBuffer, blend: "dest-over" }])
      .png()
      .toBuffer();
  } catch (e) {}
}

Server

To interface with the Devicer service, a REST API was created that accepted parameters for the device frame generation. The API was served using Express, a Node.js web application framework.

Setting up an express server was simply done by creating an express instance and then calling its listen method to run the server. The root of the server was chosen as the endpoint for device frame generation. When a request is received, the parameters are extracted from the request and then supplied to a Devicer instance, which is used to generate the device image. If all successful, the image is then sent as response in .png format, with a successful OK HTTP status code.

const app = express();

app.get("/", async (req, res, next) => {
  try {
    const params = extractDevicerParameters(req);
    const devicer = new Devicer(params);
    const deviceImage = await devicer.generate();
    res.contentType("image/png").status(StatusCode.SuccessOK).end(deviceImage);
  } catch (err) {
    return next(err);
  }
});

const PORT = 8000;

app.listen(PORT, () => {
  console.log(`Server is running at https://localhost:${PORT}`);
});

The extractDevicerParameters method is used to check the validity of, and then extract the parameters from the request. If the request's query object does not conform to the required shape, an informative error is thrown and sent to the client by use of a custom ClientFacingError error.

function extractDevicerParameters(req: Request): DevicerParams {
  const { query } = req;
  const exampleParamsTemplate =
    "Query parameters should be supplied in the form: { url: 'string', deviceID: 'string' }.";

  if (!query || Object.keys(query).length === 0) {
    throw new ClientFacingError(
      StatusCode.ClientErrorBadRequest,
      `No query parameters supplied. ${exampleParamsTemplate}`
    );
  }
  if (typeof query.url !== "string" || typeof query.device !== "string") {
    throw new ClientFacingError(
      StatusCode.ClientErrorBadRequest,
      `Query parameters incorrect. ${exampleParamsTemplate}`
    );
  }
  return {
    url: query.url,
    deviceID: query.device,
  };
}

The ClientFacingError was created to standardise the format of errors that are thrown by the service, and are handled by and error middleware function used by the express server. The ClientFacingError class requires a status code and can optionally contain a body message. Any time one of these errors is thrown, the middleware will send a response to the client with the status code and body message.

class ClientFacingError extends Error {
  statusCode;
  body;

  constructor(statusCode: StatusCode, body?: any) {
    super();
    this.statusCode = statusCode;
    this.body = body;
  }
}

export const errorMiddleware: ErrorRequestHandler = (err, req, res, next) => {
  /* If a client facing error, send error as a reponse */
  if (err instanceof ClientFacingError) {
    res.status(err.statusCode).send(err.body);
  } else {
    /* Send generic error. */
    res.sendStatus(StatusCode.ServerErrorInternal);
  }
};

app.use("/", errorMiddleware);

An additional API endpoint was created to allow clients to retrieve a full list of all available devices that can be used.

app.get("/devices", async (req, res) => {
  res.send(Object.keys(devices));
});

Get in touch

I'm always open to new ideas, new opportunities and new people. If you've got any of those things, then send a message over to me using the form below!

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.