Web developers have historically sworn allegiance to two layers of the web stack - frontend or backend. Some even choose both layers, and call themselves fullstack developers.

In a typical web application, your backend makes logical decisions on your server. Your frontend makes logical decisions on your users' devices. You want to deliver your static assets as quickly as possible to your users' devices, so they can load up your frontend faster. You probably accomplish this by putting your JavaScript, CSS, images, and video on a CDN, which stores the assets on servers that are closer to your users.

Your typical CDN offers some nice features. Static files are served from many servers around the world, so users don't have to wait as long for them. The edge servers can cache the resources, so they can be delivered to your users even more quickly. If you're lucky, you can even define unique rules for requests that match a regular expression.

But we think your typical CDN is too simple, so we made the edge programmable.

Moving backend logic to the edge

Suppose we have an application that serves stock photos. The photos are high quality and high resolution, and everyone seems to want them. So much so that we want to start restricting downloads of them, based on Referer (the Referer [sic] header).

Referer isn't the safest way to restrict content, as it is pretty easy to fake. For our purposes though, it will get the point across to users: we want you to come to our site to get our content. If you get it any other way, you're going to get a poor version of it.

For requests that contain a Referer that we recognize, we'll respond with the high quality/high resolution original. If we get a Referer we don't recognize, or no Referer, we'll respond with a watermarked version.

The Old Way

Without Fly, we'd need to make a decision about which image gets served from our backend server.

Suppose a user wanted to look at an image of a tiny kitten. If a request made it to our backend server and it had a Referer we recognize, we'd render an image named tiny-kitten-full.jpg.

If a request came into the backend server with a Referer we don't recognize, we'd render an image named tiny-kitten-watermarked.jpg.

Our backend server would render multiple versions of the image, based on the Referer. Our CDN would be responsible for serving up those two images. This works....but it'd be nice if we could make the decision of which image to serve closer to the user.

The New Way

With Fly Edge Apps, we never need to check the Referer on the backend. The decision of which image to serve is moved closer to the user - at the edge.

Our backend server would render one image - named tiny-kitten.jpg.

When our Fly Edge App serves up our tiny-kitten.jpg image, it can check the Referer. If it recognizes the Referer, we'd serve up the original image. If not, we can add a watermark to the image, and return that instead.

Let's see what the code looks like for this app!

A Fly Edge App to restrict images by Referer

Our Fly Edge handler looks like this:

fly.http.respondWith(async function(request) {
  const referer = request.headers.get('referer');

  if (isValidReferer(referer)) {
    return originalImage(request);
  } else {
    return watermarkedImage(request);
  }
});

We grab the value of the referer header from the request. If we find that it is valid, we return the original image. If the Referer is not valid, we return a watermarked image.

That's the general flow of our Fly Edge app. We're missing the implementations of several important functions, though. Let's fill them in!

Validating Referers

We'll use a very simple test to validate Referers. If the Referer is equal to our (completely made-up) URL, we'll consider it valid.

function isValidReferer(referer) {
  return referer === 'https://totally-bogus-url.com';
}

You might want to do something more complex than this in real life. Maybe you have a regular expression you want to use, or a white-list of Referers.

Returning the original image

For cases when we want to return the original image, our function looks like this:

async function originalImage(request) {
  const imagePath = extractImagePath(request);

  const body = await loadImageBuffer(`file://assets${imagePath}`);

  return new Response(body, {
    headers: {
      'Content-Type': 'image/jpg',
    },
  });
}

We'll extract the path of the requested image, load the original image, then return it as a Response object, with Content-Type of image/jpg.

Extracting the requested image path

The first thing we'll need to do is identify the path of the image being requested. We do this in extractImagePath:

function extractImagePath(request) {
  const url = new URL(request.url);
  return url.pathname;
}

We're using the URL object to extract a pathname property from the request URL.

Loading the original image

In the loadImageBuffer function, we'll use the fetch API to retrieve the original, and return an ArrayBuffer:

async function loadImageBuffer(path) {
  const response = await fetch(path);

  if (response.status != 200) {
    throw new Error("Couldn't load image: " + path);
  }

  return await response.arrayBuffer();
}

If the fetch fails, and response.status is not 200, we throw an Error. When this happens, Fly will return a status of 500 to the original request.

Where do the original images come from?

In this example, we're storing the original as a file on our file system. You can tell by the path of the file, which begins with file://.

const body = await loadImageBuffer(`file://assets${imagePath}`)

Check out our docs to learn more about how files work.

In a more complex app, you might want to retrieve your images from an Amazon S3 bucket, or some other place your assets are stored.

Returning a watermarked version

Requests from unknown Referers will fall into the watermarked branch of our app. The code for this branch looks like this:

async function watermarkedImage(request) {
  const imagePath = extractImagePath(request);

  const [original, logo] = await Promise.all([
    loadFlyImage(`file://assets${imagePath}`),
    loadFlyImage(`file://assets/logo.png`),
  ]);

  const body = await applyWatermark(original, logo);

  return new Response(body.data, {
    headers: {
      'Content-Type': 'image/jpg',
    },
  });
}

We reuse our extractImagePath function to identify the image being requested. We use Promise.all to wait for two requests to complete concurrently: one that retrieves the originally requested image (using the extracted imagePath), and one that retrieves our logo.

Given those assets, we'll apply a watermark to the original, then return the watermarked version as a Response object, with Content-Type of image/jpg.

Loading Fly images

When we were returning the original image, we loaded images as a buffer. In order to watermark our image, we're going to want to load this buffer into a Fly Image object. We do this in a loadFlyImage function:

async function loadFlyImage(path) {
  const buffer = await loadImageBuffer(path);
  return new Image(buffer);
}

Applying the watermark

Our applyWatermark function will take two Image objects: the original, and a logo.

async function applyWatermark(original, logo) {
  original.overlayWith(logo, { gravity: Image.gravity.center });

  return await original.toBuffer();
}

We call overlayWith on our original Image object, and then return it as a buffer.

There are lots of cool things we can do with Image objects. You can resize, crop, add opacity, convert formats...all sorts of things. You can read more about them here.

More possibilities

In this example, we're restricting by Referer, and returning a watermarked image. Here are some other things you could do to restrict your images:

  • Rate-limit the amount of times a particular image is served.
  • Return a smaller image (using the fly/image module).
  • Restrict images for anonymous users, but serve the original to authenticated users (using an Authentication header in the request).

Conclusion

All of the things we did in this app to restrict image access could have been done on the server, but we were able to accomplish it all at the edge. This moves complexity closer to the users. They no longer have to wait for our backend servers to process their images. They get their images more quickly, and that makes them happy!

There are many other uses for Fly Edge apps. Check out our articles for more examples!