arrows
Image by GDJ

Static web sites are awesome. They are fast, scale well, and are cheap to host. One unfortunate drawback is the necessity of using HTML to redirect to another URL.

However, this limitation can be overcome if the site is hosted on S3.

TL;DR Create an object in an S3 bucket with a Key that corresponds to the desired source URL and set the Website-Redirect-Location metadata property on the object to the target URL. Requests for the object will be redirected to the configured URL (works with or without CloudFront assuming static website hosting is enabled).

Use parse-refresh-redirect with s3-publish to set Website-Redirect-Location automatically as files are uploaded.

The Problem

Static site generators like Hugo will output a file similar to the one below in order to redirect one page to another.

public/page/1/index.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!DOCTYPE html>
<html>
  <head>
    <title>https://blog.atj.me/</title>
    <link rel="canonical" href="https://blog.atj.me/" />
    <meta name="robots" content="noindex" />
    <meta charset="utf-8" />
    <meta http-equiv="refresh" content="0; url=https://blog.atj.me/" />
  </head>
</html>

This works well enough when visiting the site in a browser, but the redirect does not occur when accessing the site via other means (curl for example).

For this and other reasons (SEO, etc.) it’s better for the server to return a proper 301 or 302 response when a redirect is intended (as opposed to a 200 response with an HTML body that contains a <meta http-equiv="refresh"> element).

The Solution

Setting the Website-Redirect-Location metadata property of an S3 object will cause requests for that object to return a 302 redirect response.

See configuring a webpage redirect for how to manually set this for individual files.

Note: This solution leaves the file content unchanged, so the redirects also work as they normally do when running the site locally for development.

Automation

s3-publish and parse-refresh-redirect were purpose-built (by me) to make this sort of thing easier.

Requirements

  • Node.js LTS (includes npm and npx)

Getting Started

If your project does not already have a package.json file, create one:

npm init -y

Install s3-publish and parse-refresh-redirect as development dependencies:

npm install -D s3-publish parse-refresh-redirect

Creating a Config File

Create a .s3p.config.js file in your project root:

npx s3p init

The example below will upload all (non-hidden) changed/missing files in the ./public directory to the S3 bucket named blog.atj.me and set the Website-Redirect-Location metadata (via the WebsiteRedirectLocation param) if the file has the extension .html and it contains a <meta http-equiv="refresh" content="..." /> element.

.s3p.config.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const { promises: fs } = require('fs');
const { resolve: resolvePath } = require('path');
const parseUrl = require('parse-refresh-redirect');

const origin = {
  root: './public',
  // Optional - ignore hidden files and directories in origin
  ignorePatterns: ['.*']
};

const target = {
  root: 's3://blog.atj.me',
  delegate: {
    // Define putFileParams method to override PUT request parameters
    putFileParams: async (file, params) => {
      let url;
      if (
        // Only process files with extension '.html'
        file.Key.endsWith('.html') &&
        // Read file and parse URL (will be undefined if not found)
        (url = parseUrl(
          await fs.readFile(resolvePath(origin.root, file.Key), 'utf8')
        ))
      ) {
        return {
          ...params,
          WebsiteRedirectLocation: url
        };
      }
      return Promise.resolve(params);
    }
  }
};

module.exports = {
  origin,
  target,
  // Optional - delete files in target not present in origin
  delete: true,
  // Required - .s3p.config.js schema version (always 2)
  schemaVersion: 2
};

Uploading Files

Use the following command to upload all changed files from the origin to the target and delete any files found in the target not present in origin:

npx s3p sync

You will be prompted before any operations are performed. Use -y to skip prompt and proceed or -n for a dry run.

Updating an Existing Site

Nothing to do

When you run npx s3p sync and the origin files already match the target files, you will be greeted with a “Nothing to do” message.

To force files to be re-uploaded (for the purposes of setting metadata, etc), use the -c argument to skip the comparison and assume all files have changed.

First Run

npx s3p sync -c -i '**/*.*' -i '!**/*.html' --no-delete

Running the command with the above options will upload all HTML files (that are not otherwise ignored) without comparing the MD5 hashes to determine if the files have changed (to ensure the metadata is set).

Note: Files are not deleted by default; the --no-delete argument is included in the example in case delete: true is set in the config file. There are no negative effects to passing --no-delete unnecessarily.