Content Negotiation with Cloudflare Workers
- 16 min read - Text OnlyA neat feature exists on the web, since the web 1.0 days in fact! Content Negotiation is a way for a user agent (think browser) to say I can take or handle these things please!
Content negotiation was first codified in RFC2068 section 14.1 in 1997, originally drafted in 1995. This header, the Accept
header goes waaaay back.. Along with Accept
came a few others, like Accept-Language
, Accept-Charset
, Accept-Encoding
. I will be focusing on Accept
.
In practice, the browser sends a prioritized list of "MIME Content-Types" (see RFC1341 from 1992) and the server must decide which content to send back. If the Content-Type
header is omitted or no matches are found, the */*
option is used. Generally, the */*
option means "I'll take whatever you'll give me".
Though, the example above is more relevant to emails than browsers. What browsers out there ask for text first? None. Emails can receive multiple content-type bodies at once, and it is up to the client to choose which to give to the user.
So here we see Content negotiation for humans isn't actually all that useful.
Wait, so why am I writing about something useless?
It is useful! But for programs rather than humans!
Brief reflection on its origins
Back in 1995 when RFC2068 was still being drafted, I think they had big dreams about the web.
We didn't know how powerful browsers would become, that Google Chrome became the basis for nearly all browsers, and many applications through Electron or even be wrapped in a light operating system.
But we did gaze into a theoretical future with extensible applications, browsers, and servers. What we lacked was insight into how difficult some of these features are to use effectively.
Do you really think servers out there bother to convert UTF-8 to ISO-8859-1? No. They don't. Today the client is responsible for interpreting what charset the server sends back.
Big companies like Google, Microsoft, eBay, Paypal, IBM, DHL, Fedex, Walmart,... the list goes on do not respect the Accept-Language
header.
The Network Working Group expected servers to be more flexible than the industry would realize. And so we have flexible clients instead.
That said... when the server is flexible, we can do some neat things!
Negotiating Requests in practice
Some platforms such as Ruby on Rails and Spring MVC support content negotiation. While Rails has an explicit code path depending on the content type, other frameworks like Spring have the developer populate a model
which is then transformed into the content type's format by the framework. See Spring MVC Content Negotiation for more. Alternatively, multiple controller endpoints can be specified, one for each supported content type!
As a content negotiating server, this provides flexibility for more types of clients. With Spring, a developer can code it once, adapt it to the client's desired format generally, and move on to focus on interesting things. But XML? Really? Thankfully we've moved on from SOAP and most integrations have JSON. XML was made a bit too smart as a messaging format and thus comes with a lot of attack surface. Still, as a service creator you may have to meet to the whims of the clients. If you don't, a fragile contracted middleware will.
While content negotiation can be used to specify which API version is requested and to be served, or even functionality, its use is more esoteric. Large vendors like Oracle even go overboard with this, using an actual unique content type for each endpoint.
In Java you can use the @Produces
annotation or with Spring the @RequestMapping
annotation. The framework may select the appropriate handler from several with hints from the client.
@GET
@Produces("application/vnd.cendyne-v1+json")
public Response oldApi() {
return Response.ok(new Version1Response("hello")).build();
}
@GET
@Produces("application/vnd.cendyne-v2+json")
public Response newApi() {
return Response.ok(new Version2Response("hola")).build();
}
Lastly, while browsers may receive a fallback response, API clients will likely have a better time if the server quickly responds with unsupported format.
____
in diagram above.Content negotiation is a reliable mechanism for differing clients to communicate with one server; the server satisfies each client within the same process for a predefined set of content types.
Now let me drop a bombshell: browsers are clients too!
Browsers again
Above, I said that content negotiation doesn't make sense for humans. Yeah. And I stand by that.
It's what's on the page the browser receives that can be content negotiated!
See, not all browsers (looking at you, Internet Explorer Apple Safari) support the same features and content.
It took Safari 6 years to add webp image support.
Today, Safari does not support avif images, even though avif provides better results in the same or less size as a similar webp.
There are ways around this. Web specs for picture and source elements can give hints to the client which to use.
But it can get gnarly.
<picture>
<source type="image/jxl" srcset="/assets/stickers/nervous.jxl">
<source type="image/avif" srcset="/assets/stickers/nervous.avif">
<source type="image/webp" srcset="/assets/stickers/nervous.webp">
<img loading="lazy" alt="nervous" class="sticker" src="/assets/stickers/nervous.jpg">
</picture>
So what does it look like if you rely on content negotiation? The client gets less HTML!
<img loading="lazy" alt="nervous" class="sticker" src="/assets/stickers/nervous">
Both with the <picture><img /></picture>
and <img />
case, the browser sends requests that look like the following:
GET /assets/stickers/nervous
Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows Phone OS 7.0; Trident/3.1; IEMobile/7.0; NOKIA; Lumia 1320)
Now just cutting off the extension won't work outright with most servers. Content negotiation is a server responsibility and most do not implement it. Cloudflare will not do it for you, and if you do manage it with nginx, openresty, or even node then Cloudflare caching will get in the way!
Headers make things complicated. It is easier to just cache requests by the URL rather than the URL and select headers.
There is a way around this, implement content negotiation at the edge with Cloudflare workers.
Cloudflare Workers
See, Cloudflare Workers is the first thing the Cloudflare endpoint will use. Within the worker, one can use the caches
API to match
against the incoming request, or more importantly: a custom request that encodes what you care about in the URL!
Following the example above, that means if the request looks like
GET /assets/stickers/nervous
Accept: image/avif,image/webp,image/jpeg
Then the cache may be asked for a request that looks like
GET /assets/stickers/nervous.avif
The worker can query the origin for /assets/stickers/nervous.avif
or /assets/stickers/avif/nervous.avif
. The origin path really does not have to equal the inbound request at all.
A client does not choose if they get a worker or not, the cloudflare endpoint does. From there the worker may choose to access other things like origins, cache, and services.
If you decouple the cache and origin requests from the client request, you can perform some convenient magic.
Here's a reduced bit of code I use to do some content negotiation for my stickers.
let varies = ['Accept'];
let acceptHeader = c.req.header('accept');
let desiredContentType;
if (acceptHeader) {
for (let accept of acceptHeader.split(',')) {
let type = accept.split(';')[0];
let foundMatch = false;
switch (type) {
case 'image/jpeg':
case 'image/png': {
desiredContentType = 'image/*'
foundMatch = true;
break;
}
case 'image/avif':
case 'image/webp':
{
desiredContentType = type;
if (type == 'image/avif') {
urlType = '/avif';
} else if (type == 'image/webp') {
urlType = '/webp'
}
foundMatch = true;
break;
}
}
if (foundMatch) {
break;
}
}
}
let cacheRequest = new Request(`https://${hostname}/s/${character}/${name}/${size}${urlType}`);
let cachedResponse = await caches.default.match(cacheRequest);
if (cachedResponse && !c.req.header('via')) {
let returnResponse = new Response(cachedResponse.body, cachedResponse);
returnResponse.headers.set('vary', varies.join(', '));
return returnResponse;
}
The cache will store requests that look like
GET /s/cendyne/wink/256/avif
There's more neat things like Client Hints such as DPR (Device Pixel Ratio), Width (yes image requests can say how wide it expects to be!), and even Save-Data to request lower bandwidth content. Though if you dabble in that, you'll also want to use Accept-CH
, and Permissions-Policy
headers too.
What's not shown in the above code is the size query parameter from the url, which may also be modified by the DPR
header or the Width
header.
After this, some logic happens to lazily produce the response.
- Make a KV
key
which encodes the asset, content type, and desired size - Search KV for this
key
- If something, return it
- Resize and convert the original asset to the desired content type and desired size
- Save the result to KV, so all future requests are instant
- Respond with the converted asset
While my sticker service stores its images in Cloudflare KV, my media service is backed by Cloudflare R2, which is a simple object storage service (like Amazon S3) since I expect far more storage in its lifetime.