Semantic Version Redirects at the Edge

Joe Schmitt
Compass True North
Published in
7 min readNov 19, 2018

At Compass, we’re big proponents of using NPM and semver (semantic versioning) when distributing our shared components as packages. NPM provides us with an industry-standard platform to publish our internal dependencies to. The tools and technologies someone learns while working on a package at Compass are the same ones they’ll use in projects in the open source community. Meanwhile, semantic versioning itself plays a huge role in providing peace of mind. Users of shared components know when updates are safe enough to upgrade to, and component authors can make big updates without the fear of silently breaking the contracts they’ve made with their users.

We’re happy enough with this system that we’ve started using it for more than just our JavaScript libraries. Cx, the CSS implementation of our design system, is itself an NPM package. The CSS source is maintained as its own scoped package, and published using the same semver rules that apply to JavaScript libraries. The Design System Team tasked with maintaining the library were especially thrilled with this setup, as they could make major sweeping design changes to the library and mark them as breaking changes, allowing Compass application teams that used the library the freedom to upgrade at their leisure simply by updating their application’s dependencies:

{
"name": "compass-search-app",
"dependencies": {
"@uc/cx": "1.2.3",

}
}

Dependency Hell

After a few months of maintaining the library, we started hearing about some pain teams were feeling with Cx being semantically versioned and installed as a dependency.

On the Design System Team’s side, they were frustrated that their bug fix updates were slow to roll out to apps in production. Each team listed Cx as a dependency with an exact version number, which was great for stability and consistent deployments, but it did mean they’d have to explicitly opt-in to any new changes to the design system. Additionally, multiple apps across multiple teams might be pointing to a different specific semantic version of the library, leading to slight differences across apps and multiple loads of the library.

On the application owners’ side, since Cx was listed as a dependency, application authors were required to use a build process just to use the library. CSS itself does not automatically support loading styles from a package.json, and so even extremely simple use cases necessitated using webpack or postcss as a build process in order to integrate the library.

There had to be a better way.

Deploying Cx

To solve the pain our teams were feeling, we decided the best course of action was to host Cx on our AWS-based CDN, backed by S3 and Cloudfront.

Hosting the library remotely instead of installing it as a dependency in each application leads to a few advantages. First of all, integration becomes much easier. Instead of having to install the library into an application and integrate it into a build process, application owners can simply use an HTML <link> tag and reference the library like any other CSS file. As an additional bonus, once the library is downloaded by the browser, it can be cached, meaning less CSS to download across multiple applications. Since all of our applications standardized on our same basic CSS library, this could lead to saving lots of additional requests.

Implementing this into our NPM-backed package ecosystem ended up being relatively straightforward. NPM defines a set of scripts for package.json files that are automatically executed at different points in a package’s lifecycle. After reviewing the lifecycle events, the postpublish lifecycle hook seemed like the most obvious time to deploy our library. We created a command-line tool for deploying static assets to our CDN, and on postpublish the assets are uploaded:

{
“name”: “@uc/cx”,
“scripts”: {
“prepublish”: “postcss src/index.css --output dist/cx.min.css”,
“postpublish”: “uc-deploy-assets dist/cx.min.css”
}
}

The uc-deploy-assets command-line tool handles uploading assets to our CloudFront-backed S3 bucket, and automatically places those assets in a folder based on the name of the package. That asset is then available from a public url with a stable, defined URL scheme linkable from the HTML:

<link rel=”stylesheet” type=”text/css” href=”/cx/1.2.3/cx.min.css”>

Dependency Hell, This Time in the Cloud

We immediately ran into a problem here.

Part of the folder structure and url scheme is grouping the deployments by published version number. This is great for keeping all of the advantages of semver, but then application owners would have to update the <link> tag in their HTML every time a bug fix or new feature was rolled out by the Design System Team. On the other hand, we also didn’t want to eliminate all of the advantages of semantic versioning we’d enjoyed when moving towards this deploy-based usage of Cx.

Uploading Cx to the same location and URL every time it’s updated would simplify integration for applications by allowing them to link to a single URL, but it brings us back to that pre-semver world. What we needed was the ease of use of linking to hosted CSS files with the additional stability and safety of a version-backed library.

Enter Semantic Routing

After doing some thinking and exploration, we realized that NPM themselves had already solved this problem. When an application declares multiple dependencies with common dependencies among them, it’d be wasteful to have 3 versions of the same dependency in the app. The semver range syntax was created to solve this exact problem. Apps and packages can declare a range of versions they decide they’re compatible with, and as long as all 3 dependencies have overlapping ranges, you can resolve that down to a single, compatible version.

We could use a similar concept for our hosted CSS files (or any static assets, really) by using URL redirects. Instead of an application linking to an exact version of the library, they could choose to link to a redirect URL based on semantic version range rules:

  • /cx/1.2/cx.min.css
  • /cx/1/cx.min.css
  • /cx/latest/cx.min.css

Assuming version 1.2.3 is the latest version of Cx, then all 3 URLs would resolve to the exact same asset: version 1.2.3. However, given a few more iteration cycles of Cx where-by a patch was published (1.2.4), a new feature was added (1.3.0), and then a breaking change was made (2.0.0), then the 3 urls would resolve as so:

  • /cx/1.2/cx.min.css -> /cx/1.2.3/cx.min.css
  • /cx/1/cx.min.css -> /cx/1.3.0/cx.min.css
  • /cx/latest/cx.min.css -> /cx/2.0.0/cx.min.css

This allows applications to decide for themselves what tradeoff they’re willing to make between automatically receiving updates and their own app’s stability.

Implementing Semver Routing

Initially, we looked at implementing these rules through S3’s simple website redirect syntax. We were already writing our own S3 command-line tool for uploading, and so adding redirect rules to the upload was just a question of using their API:

const Redirect = { 
Protocol: protocol,
HostName: hostname,
ReplaceKeyPrefixWith: `${name}/${version}/`
};
s3Client.putBucketWebsite({
Bucket: bucket,
WebsiteConfiguration: {
RoutingRules: {
Condition: {KeyPrefixEquals: `${name}/latest/`},
Redirect,
}, {
Condition: {KeyPrefixEquals: `${name}/${semver.major(version)}/`},
Redirect,
}, {
Condition: {KeyPrefixEquals: `${name}/${semver.major(version)}.${semver.minor(version)}/`},
Redirect,
}
},
});

We were even able to leverage NPM’s own semver package to grab the major and minor version numbers!

Unfortunately, this implementation ended up being too naive for the scale that we needed. Within a few weeks of rolling this system out to more internal packages that need to deploy static assets, we ran into a not-terribly well-documented limit on the S3 side with redirects: they’re capped at 50 total redirects per S3 bucket. That’s obviously not going to work, we needed a new plan.

Routing @ Edge

We reached out to AWS support about potentially raising the limit. They told us the limit was system-wide and could not be changed, but to potentially look at working through our problem by using a new product they were rolling out called Lambda@Edge.

Lambda@Edge allows you to run code on the edge nodes used by Cloudfront. We could run our logic to figure out how to redirect to specific asset versions without losing the benefits of a fully cached and fast-loading CDN.

Lambda semver-redirect

The lambda function boils down to the following flow, which we separated out into separate, individually unit-testable (!!!) functions:

1. Receive the Lambda@Edge event in our event handler. The event has lots of information and context about the asset being requested, but we’re mostly interested in a few fields inside the request field.

{
"Records": [{
"cf": {
"request": {
"method": "GET",
"origin": {
"custom": {
"domainName": "bucket-name.s3.amazonaws.com",
"port": 443,
"protocol": "https"
}
},
"uri": "/deploy-assets/latest/compass-tech.png"
}
}
}]
}

2. From the request field, we can parse out 3 important pieces of information: the S3 bucket name, the path to the asset, and the semver range.

const {bucket, path, range} = parseRequest(request);

3. With the bucket name and asset path in hand, we can make a request to the bucket to list out all the possible versions that have been deployed. We can do this because we standardized on uploading files to a folder named after the semver version. Getting the list of objects in a given directory on S3 will give us those versions. Any folder names that are not valid semver can be safely ignored and filtered out.

// Returns an array of version numbers: ['1.2.0', '1.2.1', '2.0.0']
const versions = await listAssetVersions(bucket, path);

4. Resolve the exact version we need from the range we want. This is actually super easy because the semver NPM library has a function called maxSatisfying. This is extra cool because it takes any kind of range syntax, meaning our redirect url will support any valid range syntax that the semver package supports, even craziness such as >=1.2.7 <1.3.0 in our urls.

const exactRange = semver.maxSatisfying(versions, range);

5. Rewrite the uri field we received from Lambda@Edge to point to the exact asset (if all above steps succeeded).

const exactPath = getExactPath(request.uri, exactRange);
event.Records[0].cf.request.uri = exactPath;

If any of them fail, let the event object proceed unmodified and request the original URL path.

Hooking it all together

Now that the lambda logic is written (and fully unit tested!!), we need to hook the lambda up to Cloudfront. In our Cloudfront distribution, we add a new Cloudfront Event association as a Behavior. In it, we say that whenever an Origin Request happens (meaning the asset being requested isn’t cached by Cloudfront already so it has to request it from the origin), it should trigger running our Lambda function. Whenever a url is requested on this Cloudfront distribution, it will run our Lambda function (if it hasn’t already cached the response already) and our function can either ignore it and let it pass unmodified to S3, or direct it to an exact asset!

We’ve been running this code in production for a few months now, and it’s been working great. We’ve recommended to all of our applications to use static assets this way, and to default to pointing to major.minor redirect urls so that they automatically get bug fixes when they’re rolled out. We’re excited by the possibilities that this unlocks, and are looking forward to leveraging it further in the future.

--

--

Joe Schmitt
Compass True North

Senior Staff Software Engineer at Compass, Co-Editor of the Compass True North Blog. Formerly Made for humans, Vimeo, Fi.