How to use Drupal to optimize images on the fly

Image styles are a powerful tool in Drupal which allow users to define transformations and optimizations for images. These image styles are usually configured in the Drupal space via the UI and then generated and outputted into the content by the rendering pipeline.

But how does this work when you're using Drupal as a headless CMS? Drupal's core module JSON:API means you can access all the content in the CMS via a standardized JSON interface. However, when it comes to images you can only access the raw image file URLs. There's not a great way to generate and consume image derivatives when you're running headlessly. You can't define a new image style from the outside on the fly.

There are third-party services out there like Cloudinary or Fastly's image optimizer which empower developers to proxy their images through a CDN and apply these transformations and optimizations on the fly. These services boil down to an application which takes some transformation parameters, apply these transformations to an image and then cache and return the derivative image. With Drupal at our fingertips, surely we could handle this without adding another service into the mix.

This is where the Image Optimization API module comes in. It allows developers to take full advantage of Drupal's image styles and ImageToolkit abstraction to generate derivatives on the fly using just URL parameters. All the images on this site take advantage of this module to resize and reformat. With Gatsby's gatsby-plugin-image you can even avoid setting up the URL generation. Gatsby will ask for a picture element containing multiple source images at different sizes and formats and it can be told how to generate URLs for each image size and format.

To illustrate the power of this, here is an example URL implementing a simple resize operation:

https://cms.desarol.com/image-transformation-api/cHVibGljOi8vMjAyMS0wOC9waG90by0xNTIxNzM3ODUyNTY3LTY5NDlmM2Y5ZjJiNS5qcGVn/ c_scale,width_240,height_165|c_convert,extension_webp?s=lomXSzMFzJyj110xY7B95_NmBYMDmfYlP4c1HBfDa6w

As you can see here in the URL, the operation (scale) and parameters (width and height) are specified. After that an additional transformation is done (convert) which changes the output format into webp. Notice the s query parameter which provides a signature, verifying that the user generating this image derivative is authorized. This functions similar to Drupal's native itok query parameter.

To understand further how one might use this in Gatsby, let's look at how we could hook it up to pull images for Media from Drupal:

// gatsby-node.ts
import crypto from 'crypto'
import got from 'got'
import { GatsbyNode } from 'gatsby'
import { generateImageData } from 'gatsby-plugin-image'
import { getGatsbyImageFieldConfig } from 'gatsby-plugin-image/graphql-utils'

export const makeBase64UrlSafe = (b64: string) => (
  b64
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '')
)

export const urlSafeBase64 = (str: string) => makeBase64UrlSafe(
	Buffer.from(str, 'ascii').toString('base64')
)

const getDerivativeSignature = (
  urlSafeBase64EncodedPublicUri: string,
  serializedTransformations: string,
) => makeBase64UrlSafe(
  crypto
	.createHmac('sha256', PRIVATE_SIGNING_KEY)
	.update(`${urlSafeBase64EncodedPublicUri}::${serializedTransformations}`)
	.digest('base64')
)

const generateImageSource = (
	publicUri: string,
	width: number,
	height: number,
	format: 'jpeg' | 'png' | 'webp'
) => {
  let appliedImageFormat
  const supportedImageFormats = ['jpeg', 'png', 'webp']

  const transformations = [
    `c_scale,width_${width},height_${height}`,
  ]

  if (supportedImageFormats.includes(format)) {
    transformations.push(`c_convert,extension_${format}`)
    appliedImageFormat = format
  } else {
    transformations.push('c_convert,extension_webp')
    appliedImageFormat = 'webp'
  }

  const urlSafeBase64EncodedPublicUri = urlSafeBase64(publicUri)
  const serializedTransformations = transformations.join('|')

  return {
    src: `https://cms.desarol.com/image-transformation-api/${urlSafeBase64EncodedPublicUri}/${serializedTransformations}?s=${getDerivativeSignature(urlSafeBase64EncodedPublicUri, serializedTransformations)}`,
    width,
    height,
    format: appliedImageFormat,
  }
}

const getBase64Image = async (imageUrl: string) => {
  const data = await got({
    method: 'GET',
    url: imageUrl,
    responseType: 'buffer',
    headers: {
      accept: 'image/png',
    },
  })

  return data.body.toString('base64')
}

const resolveGatsbyImageData = async (mediaImage: any, options: any, context: any) => {
  // The `mediaImage` argument is the node to which you are attaching the resolver,
  // so the values will depend on your data type.
  const relatedFileNode = (
    context
      .nodeModel
      .getAllNodes({ type: 'file__file' })
      .filter((file: any) => file?.filename === mediaImage?.name)
  )?.[0]

  const filename = relatedFileNode?.uri?.value
  const sourceMetadata = {
    width: mediaImage?.field_media_image?.width,
    height: mediaImage?.field_media_image?.height,
    format: relatedFileNode?.filemime?.split('/')?.[1],
  }

  const imageDataArgs = {
    ...options,
    // Passing the plugin name allows for better error messages
    pluginName: 'gatsby-source-drupal',
    sourceMetadata,
    filename,
    generateImageSource,
    options,
  }

  // Generating placeholders is optional, but recommended
  if (options.placeholder === 'blurred') {
    const sWidth = sourceMetadata.width
    const sHeight = sourceMetadata.height

    let scaleRatio
    if (sWidth < sHeight) {
      scaleRatio = 20 / sHeight
    } else {
      scaleRatio = 20 / sWidth
    }

    // This function returns the URL for a 20px-wide image, to use as a blurred placeholder
    // You need to download the image and convert it to a base64-encoded data URI
    const { src: thumbnailUrl } = generateImageSource(filename, sWidth * scaleRatio, sHeight * scaleRatio, 'png')

    // This would be your own function to download and generate a low-resolution placeholder
    imageDataArgs.placeholderURL = `data:image/png;base64,${await getBase64Image(thumbnailUrl)}`
  }

  // You could also calculate dominant color, and pass that as `backgroundColor`
  // gatsby-plugin-sharp includes helpers that you can use to generate a tracedSVG or calculate
  // the dominant color of a local file, if you don't want to handle it in your plugin
  return generateImageData(imageDataArgs)
}

export const createResolvers: GatsbyNode['createResolvers'] = ({ createResolvers: createResolvers_ }) => {
  createResolvers_({
    media__image: {
      gatsbyImageData: {
        ...getGatsbyImageFieldConfig(resolveGatsbyImageData),
      },
    },
  })
}

Unpacking the magic

So let's start from the Gatsby API and work backwards to understand what's going on here. First off, we want to create a resolver for the gatsbyImageData on the media__image so we're setting that up with these lines:

export const createResolvers: GatsbyNode['createResolvers'] = ({ createResolvers: createResolvers_ }) => {
  createResolvers_({
    media__image: {
      gatsbyImageData: {
        ...getGatsbyImageFieldConfig(resolveGatsbyImageData),
      },
    },
  })
}

The helper function getGatsbyImageFieldConfig comes from gatsby-plugin-image and is a higher order function that creates the necessary config for the resolver when provided with a function that tells it how to create the image URLs. Next, let's dig into what that function does:

const resolveGatsbyImageData = async (mediaImage: any, options: any, context: any) => {
  // The `mediaImage` argument is the node to which you are attaching the resolver,
  // so the values will depend on your data type.
  const relatedFileNode = (
    context
      .nodeModel
      .getAllNodes({ type: 'file__file' })
      .filter((file: any) => file?.filename === mediaImage?.name)
  )?.[0]

  const filename = relatedFileNode?.uri?.value
  const sourceMetadata = {
    width: mediaImage?.field_media_image?.width,
    height: mediaImage?.field_media_image?.height,
    format: relatedFileNode?.filemime?.split('/')?.[1],
  }

  const imageDataArgs = {
    ...options,
    // Passing the plugin name allows for better error messages
    pluginName: 'gatsby-source-drupal',
    sourceMetadata,
    filename,
    generateImageSource,
    options,
  }

  // Generating placeholders is optional, but recommended
  if (options.placeholder === 'blurred') {
    const sWidth = sourceMetadata.width
    const sHeight = sourceMetadata.height

    let scaleRatio
    if (sWidth < sHeight) {
      scaleRatio = 20 / sHeight
    } else {
      scaleRatio = 20 / sWidth
    }

    // This function returns the URL for a 20px-wide image, to use as a blurred placeholder
    // You need to download the image and convert it to a base64-encoded data URI
    const { src: thumbnailUrl } = generateImageSource(filename, sWidth * scaleRatio, sHeight * scaleRatio, 'png')

    // This would be your own function to download and generate a low-resolution placeholder
    imageDataArgs.placeholderURL = `data:image/png;base64,${await getBase64Image(thumbnailUrl)}`
  }

  // You could also calculate dominant color, and pass that as `backgroundColor`
  // gatsby-plugin-sharp includes helpers that you can use to generate a tracedSVG or calculate
  // the dominant color of a local file, if you don't want to handle it in your plugin
  return generateImageData(imageDataArgs)
}

First off we get the related file node by querying for all file nodes that Gatsby has access to and finding the one that matches the name on the media node. Remember that in Gatsby a node is any piece of data. These nodes are not to be confused with Drupal nodes and are not necessarily always Drupal nodes (for instance media and files are also Gatsby nodes).

Once we find a matching file node we can start to extract all the data that we need to provide gatsby-plugin-image in order for it to generate our image data. We collect the file name, width, height and format of the source image. We then group all these fields into an object imageDataArgs which we can pass to generateImageData which is another helper from gatsby-plugin-image

Finally, we decide whether or not to download a 20px base64 encoded placeholder image for use when applying the blurred placeholder. This is optional and will slow down the resolver but can lead to a better UX since the user is presented with a blurred, embedded base64 encoded version of the final image before it has been loaded.

Now let's take a closer look at the generateImageSource function.

const generateImageSource = (publicUri: string, width: number, height: number, format: 'jpeg' | 'png' | 'webp') => {
  let appliedImageFormat
  const supportedImageFormats = ['jpeg', 'png', 'webp']

  const transformations = [
    `c_scale,width_${width},height_${height}`,
  ]

  if (supportedImageFormats.includes(format)) {
    transformations.push(`c_convert,extension_${format}`)
    appliedImageFormat = format
  } else {
    transformations.push('c_convert,extension_webp')
    appliedImageFormat = 'webp'
  }

  const urlSafeBase64EncodedPublicUri = urlSafeBase64(publicUri)
  const serializedTransformations = transformations.join('|')

  return {
    src: `https://cms.desarol.com/image-transformation-api/${urlSafeBase64EncodedPublicUri}/${serializedTransformations}?s=${getDerivativeSignature(urlSafeBase64EncodedPublicUri, serializedTransformations)}`,
    width,
    height,
    format: appliedImageFormat,
  }
}

This function helps us transform the requested image from Gatsby into a URL we can use to access the image from Drupal. Based on how Gatsby wants to transform our image we generate a list of transformations and serialize them into a way that the Image Transformation API module will accept. We can provide things like image format and final width and height. We also compute the signature here based on the shared secret with Drupal that prevents malicious users from attacking your Drupal site with a bunch of expensive image transformation requests.

Going further

Having access to these features integrated into Gatsby and without using a third-party service makes developing a large Drupal + Gatsby site much more manageable. The images are performant as they are loaded from your Drupal site's CDN and developers are able to leverage all the good bits of gatsby-plugin-image . However, to improve further on this one might want to add additional resolver arguments to do more complex transformations. There's also room to support the other placeholder options that Gatsby has like tracedSVG and dominantColor .

In the future this setup will be provided as a plugin so that you can keep your gatsby-node focused on your site's features and still reap all the benefits of Image Transformation API.