Skip to content

Instantly share code, notes, and snippets.

@wilsonpage
Last active April 22, 2024 08:58
Show Gist options
  • Star 50 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save wilsonpage/a4568d776ee6de188999afe6e2d2ee69 to your computer and use it in GitHub Desktop.
Save wilsonpage/a4568d776ee6de188999afe6e2d2ee69 to your computer and use it in GitHub Desktop.
An implementation of stale-while-revalidate for Cloudflare Workers
export const CACHE_STALE_AT_HEADER = 'x-edge-cache-stale-at';
export const CACHE_STATUS_HEADER = 'x-edge-cache-status';
export const CACHE_CONTROL_HEADER = 'Cache-Control';
export const CLIENT_CACHE_CONTROL_HEADER = 'x-client-cache-control';
export const ORIGIN_CACHE_CONTROL_HEADER = 'x-edge-origin-cache-control';
enum CacheStatus {
HIT = 'HIT',
MISS = 'MISS',
REVALIDATING = 'REVALIDATING',
}
const swr = async ({
request,
event,
}: {
request: Request;
event: FetchEvent;
}) => {
const cache = caches.default;
const cacheKey = toCacheKey(request);
const cachedRes = await cache.match(cacheKey);
if (cachedRes) {
let cacheStatus = cachedRes.headers.get(CACHE_STATUS_HEADER);
if (shouldRevalidate(cachedRes)) {
cacheStatus = CacheStatus.REVALIDATING;
// update cached entry to show it's 'updating'
// and thus shouldn't be re-fetched again
await cache.put(
cacheKey,
addHeaders(cachedRes, {
[CACHE_STATUS_HEADER]: CacheStatus.REVALIDATING,
})
);
event.waitUntil(
fetchAndCache({
cacheKey,
request,
event,
})
);
}
return addHeaders(cachedRes, {
[CACHE_STATUS_HEADER]: cacheStatus,
[CACHE_CONTROL_HEADER]: cachedRes.headers.get(
CLIENT_CACHE_CONTROL_HEADER
),
});
}
return fetchAndCache({
cacheKey,
request,
event,
});
};
const fetchAndCache = async ({
cacheKey,
request,
event,
}: {
request: Request;
event: FetchEvent;
cacheKey: Request;
}) => {
const cache = caches.default;
// we add a cache busting query param here to ensure that
// we hit the origin and no other upstream cf caches
const originRes = await fetch(addCacheBustParam(request));
const cacheControl = resolveCacheControlHeaders(request, originRes);
const headers = {
[ORIGIN_CACHE_CONTROL_HEADER]: originRes.headers.get('cache-control'),
[CACHE_STALE_AT_HEADER]: cacheControl?.edge?.staleAt?.toString(),
'x-origin-cf-cache-status': originRes.headers.get('cf-cache-status'),
};
if (cacheControl?.edge) {
// store the cache response w/o blocking response
event.waitUntil(
cache.put(
cacheKey,
addHeaders(originRes, {
...headers,
[CACHE_STATUS_HEADER]: CacheStatus.HIT,
[CACHE_CONTROL_HEADER]: cacheControl.edge.value,
// Store the client cache-control header separately as the main
// cache-control header is being used as an api for cf worker cache api.
// When the request is pulled from the cache we switch this client
// cache-control value in place.
[CLIENT_CACHE_CONTROL_HEADER]: cacheControl?.client,
// remove headers we don't want to be cached
'set-cookie': null,
'cf-cache-status': null,
vary: null,
})
)
);
}
return addHeaders(originRes, {
...headers,
[CACHE_STATUS_HEADER]: CacheStatus.MISS,
[CACHE_CONTROL_HEADER]: cacheControl?.client,
// 'x-cache-api-cache-control': cacheControl?.edge?.value,
// 'x-origin-res-header': JSON.stringify(toObject(originRes.headers)),
});
};
const resolveCacheControlHeaders = (req: Request, res: Response) => {
// don't cache error or POST/PUT/DELETE
const shouldCache = res.ok && req.method === 'GET';
if (!shouldCache) {
return {
client: 'public, max-age=0, must-revalidate',
};
}
const cacheControl = res.headers.get(CACHE_CONTROL_HEADER);
// never cache anything that doesn't have a cache-control header
if (!cacheControl) return;
const parsedCacheControl = parseCacheControl(cacheControl);
return {
edge: resolveEdgeCacheControl(parsedCacheControl),
client: resolveClientCacheControl(parsedCacheControl),
};
};
const resolveEdgeCacheControl = ({
sMaxage,
staleWhileRevalidate,
}: ParsedCacheControl) => {
// never edge-cache anything that doesn't have an s-maxage
if (!sMaxage) return;
const staleAt = Date.now() + sMaxage * 1000;
// cache forever when no swr window defined meaning the stale
// content can be served indefinitely while fresh stuff is re-fetched
if (staleWhileRevalidate === 0) {
return {
value: 'immutable',
staleAt,
};
}
// when no swr defined only cache for the s-maxage
if (!staleWhileRevalidate) {
return {
value: `max-age=${sMaxage}`,
staleAt,
};
}
// when both are defined we extend the cache time by the swr window
// so that we can respond with the 'stale' content whilst fetching the fresh
return {
value: `max-age=${sMaxage + staleWhileRevalidate}`,
staleAt,
};
};
const resolveClientCacheControl = ({ maxAge }: ParsedCacheControl) => {
if (!maxAge) return 'public, max-age=0, must-revalidate';
return `max-age=${maxAge}`;
};
interface ParsedCacheControl {
maxAge?: number;
sMaxage?: number;
staleWhileRevalidate?: number;
}
const parseCacheControl = (value = ''): ParsedCacheControl => {
const parts = value.replace(/ +/g, '').split(',');
return parts.reduce((result, part) => {
const [key, value] = part.split('=');
result[toCamelCase(key)] = Number(value) || 0;
return result;
}, {} as Record<string, number | undefined>);
};
const addHeaders = (
response: Response,
headers: { [key: string]: string | undefined | null }
) => {
const response2 = new Response(response.clone().body, {
status: response.status,
headers: response.headers,
});
for (const key in headers) {
const value = headers[key];
// only truthy
if (value !== undefined) {
if (value === null) response2.headers.delete(key);
else {
response2.headers.delete(key);
response2.headers.append(key, value);
}
}
}
return response2;
};
const toCamelCase = (string: string) =>
string.replace(/-./g, (x) => x[1].toUpperCase());
/**
* Create a normalized cache-key from the inbound request.
*
* Cloudflare is fussy. If we pass the original request it
* won't find cache matches perhaps due to subtle differences
* in headers, or the presence of some blacklisted headers
* (eg. Authorization or Cookie).
*
* This method strips down the cache key to only contain:
* - url
* - method
*
* We currently don't cache POST/PUT/DELETE requests, but if we
* wanted to in the future the cache key could contain req.body,
* but this is probably not ever a good idea.
*/
const toCacheKey = (req: Request) =>
new Request(req.url, {
method: req.method,
});
const shouldRevalidate = (res: Response) => {
// if the cache is already revalidating then we shouldn't trigger another
const cacheStatus = res.headers.get(CACHE_STATUS_HEADER);
if (cacheStatus === CacheStatus.REVALIDATING) return false;
const staleAtHeader = res.headers.get(CACHE_STALE_AT_HEADER);
// if we can't resolve an x-cached-at header => revalidate
if (!staleAtHeader) return true;
const staleAt = Number(staleAtHeader);
const isStale = Date.now() > staleAt;
// if the cached response is stale => revalidate
return isStale;
};
const addCacheBustParam = (request: Request) => {
const url = new URL(request.url);
url.searchParams.append('t', Date.now().toString());
return new Request(url.toString(), request);
};
export default swr;
import { Request, Response } from 'node-fetch';
import mockDate from 'mockdate';
import swr, { CACHE_CONTROL_HEADER } from './swr';
const STATIC_DATE = new Date('2000-01-01');
const fetchMock = jest.fn();
const cachesMock = {
match: jest.fn(),
put: jest.fn(),
};
// @ts-ignore
global.caches = { default: cachesMock };
global.fetch = fetchMock;
beforeEach(() => {
mockDate.set(STATIC_DATE);
cachesMock.match.mockReset();
cachesMock.put.mockReset();
global.fetch = fetchMock.mockReset();
});
describe('swr', () => {
describe('s-maxage=60, stale-while-revalidate', () => {
describe('first request', () => {
let cachesPutCall: [Request, Response];
let response;
let request;
beforeEach(async () => {
request = new Request('https://example.com');
fetchMock.mockResolvedValue(
new Response('', {
headers: {
'cache-control': 's-maxage=60, stale-while-revalidate',
},
})
);
response = await swr({
request,
event: mockFetchEvent(),
});
cachesPutCall = cachesMock.put.mock.calls[0];
});
it('calls fetch as expected', () => {
const [request] = fetchMock.mock.calls[0];
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(request.url).toBe(
`https://example.com/?t=${STATIC_DATE.getTime()}`
);
});
it('has the expected cache-control header', () => {
expect(response.headers.get(CACHE_CONTROL_HEADER)).toBe(
'public, max-age=0, must-revalidate'
);
});
it('caches the response', () => {
const [request, response] = cachesMock.put.mock.calls[0];
expect(cachesMock.put).toHaveBeenCalledTimes(1);
expect(request.url).toBe('https://example.com/');
// caches forever until revalidate
expect(response.headers.get('cache-control')).toBe('immutable');
});
describe('… then second request (immediate)', () => {
let response: Response;
beforeEach(async () => {
request = new Request('https://example.com');
cachesMock.match.mockResolvedValueOnce(cachesPutCall[1]);
response = ((await swr({
request,
event: mockFetchEvent(),
})) as unknown) as Response;
});
it('returns the expected cached response', () => {
expect(response.headers.get('x-edge-cache-status')).toBe('HIT');
});
it('has the expected cache-control header', () => {
expect(response.headers.get('cache-control')).toBe(
'public, max-age=0, must-revalidate'
);
});
});
describe('… then second request (+7 days)', () => {
let response: Response;
beforeEach(async () => {
request = new Request('https://example.com');
// mock clock forward 7 days
mockDate.set(
new Date(STATIC_DATE.getTime() + 1000 * 60 * 60 * 24 * 7)
);
cachesMock.match.mockResolvedValueOnce(cachesPutCall[1]);
response = ((await swr({
request,
event: mockFetchEvent(),
})) as unknown) as Response;
});
it('returns the expected cached response', () => {
expect(response.headers.get('x-edge-cache-status')).toBe(
'REVALIDATING'
);
});
});
});
});
describe('max-age=10, s-maxage=60, stale-while-revalidate=60', () => {
let cachesPutCall: [Request, Response];
let request;
let response;
beforeEach(async () => {
request = new Request('https://example.com');
fetchMock.mockResolvedValueOnce(
new Response('', {
headers: {
'cache-control':
'max-age=10, s-maxage=60, stale-while-revalidate=60',
},
})
);
response = await swr({
request,
event: mockFetchEvent(),
});
cachesPutCall = cachesMock.put.mock.calls[0];
});
it('calls fetch as expected', () => {
const [request] = fetchMock.mock.calls[0];
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(request.url).toBe(
`https://example.com/?t=${STATIC_DATE.getTime()}`
);
});
it('has the expected response', () => {
expect(response.headers.get('cache-control')).toBe('max-age=10');
});
it('caches the response', () => {
const [request, response] = cachesPutCall;
expect(cachesMock.put).toHaveBeenCalledTimes(1);
expect(request.url).toBe('https://example.com/');
// stores the cached response for the additional swr window
expect(response.headers.get('cache-control')).toBe('max-age=120');
});
describe('… then second request', () => {
let res: Response;
beforeEach(async () => {
request = new Request('https://example.com');
cachesMock.match.mockResolvedValueOnce(cachesPutCall[1]);
res = ((await swr({
request,
event: mockFetchEvent(),
})) as unknown) as Response;
});
it('returns the expected cached response', () => {
expect(res.headers.get('x-edge-cache-status')).toBe('HIT');
});
it('has the expected cache-control header', () => {
expect(res.headers.get('Cache-Control')).toBe('max-age=10');
});
describe('… then + 61s', () => {
let response: Response;
let revalidateFetchDeferred;
beforeEach(async () => {
// move clock forward 61 seconds
mockDate.set(new Date(STATIC_DATE.getTime() + 61 * 1000));
request = new Request('https://example.com');
// reset mock state
fetchMock.mockReset();
cachesMock.put.mockReset();
cachesMock.match.mockResolvedValueOnce(cachesPutCall[1]);
revalidateFetchDeferred = deferred();
// mock the revalidated request
fetchMock.mockImplementationOnce(() => {
revalidateFetchDeferred.resolve(
new Response('', {
headers: {
'cache-control': 's-maxage=60, stale-while-revalidate=60',
},
})
);
return revalidateFetchDeferred.promise;
});
response = ((await swr({
request,
event: mockFetchEvent(),
})) as unknown) as Response;
});
it('returns the STALE response', () => {
expect(response.headers.get('x-edge-cache-status')).toBe(
'REVALIDATING'
);
});
it('updates the cache entry state to REVALIDATING', () => {
const [, response] = cachesMock.put.mock.calls[0];
expect(response.headers.get('x-edge-cache-status')).toBe(
'REVALIDATING'
);
});
it('fetches to revalidate', () => {
expect(fetchMock).toHaveBeenCalled();
});
it('updates the cache with the fresh response', async () => {
await revalidateFetchDeferred.promise;
const [, response] = cachesMock.put.mock.calls[1];
expect(response.headers.get('x-edge-cache-status')).toBe('HIT');
});
});
});
});
describe('max-age=60', () => {
let request;
beforeEach(async () => {
request = new Request('https://example.com');
fetchMock.mockResolvedValueOnce(
new Response('', {
headers: {
'cache-control': 'max-age=60',
},
})
);
await swr({
request,
event: mockFetchEvent(),
});
});
it('calls fetch as expected', () => {
const [request] = fetchMock.mock.calls[0];
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(request.url).toBe(
`https://example.com/?t=${STATIC_DATE.getTime()}`
);
});
it('does NOT cache the response', () => {
expect(cachesMock.put).not.toHaveBeenCalled();
});
});
describe('s-maxage=60', () => {
let response;
let request;
beforeEach(async () => {
request = new Request('https://example.com');
fetchMock.mockResolvedValueOnce(
new Response('', {
headers: {
'cache-control': 's-maxage=60',
},
})
);
response = await swr({
request,
event: mockFetchEvent(),
});
});
it('caches the response', () => {
expect(cachesMock.put).toHaveBeenCalled();
});
it('has the expected response', () => {
expect(response.headers.get(CACHE_CONTROL_HEADER)).toBe(
'public, max-age=0, must-revalidate'
);
});
});
describe('no-store, no-cache, max-age=0', () => {
let response;
let request;
beforeEach(async () => {
request = new Request('https://example.com');
fetchMock.mockResolvedValueOnce(
new Response('', {
headers: {
'cache-control': 'no-store, no-cache, max-age=0',
},
})
);
response = await swr({
request,
event: mockFetchEvent(),
});
});
it('does NOT cache the response', () => {
expect(cachesMock.put).not.toHaveBeenCalled();
});
it('has the expected response', () => {
expect(response.headers.get(CACHE_CONTROL_HEADER)).toBe(
'public, max-age=0, must-revalidate'
);
});
});
describe('404', () => {
let response;
let request;
beforeEach(async () => {
request = new Request('https://example.com');
fetchMock.mockResolvedValueOnce(
new Response('error', {
status: 404,
headers: {
'cache-control': 's-maxage=100',
},
})
);
response = await swr({
request,
event: mockFetchEvent(),
});
});
it('does NOT cache the response', () => {
expect(cachesMock.put).not.toHaveBeenCalled();
});
});
describe('POST', () => {
let response;
let request;
beforeEach(async () => {
request = new Request('https://example.com', {
method: 'POST',
});
fetchMock.mockResolvedValueOnce(
new Response('error', {
headers: {
'cache-control': 's-maxage=100',
},
})
);
response = await swr({
request,
event: mockFetchEvent(),
});
});
it('does NOT cache the response', () => {
expect(cachesMock.put).not.toHaveBeenCalled();
});
});
describe('max-age=60', () => {
let request;
beforeEach(async () => {
request = new Request('https://example.com');
fetchMock.mockResolvedValueOnce(
new Response('', {
headers: {
'cache-control': 'max-age=60',
},
})
);
await swr({
request,
event: mockFetchEvent(),
});
});
it('calls fetch as expected', () => {
const [request] = fetchMock.mock.calls[0];
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(request.url).toBe(`https://example.com/?t=${Date.now()}`);
});
it('does NOT cache the response', () => {
expect(cachesMock.put).not.toHaveBeenCalled();
});
});
});
describe('s-maxage=1800, stale-while-revalidate=86400', () => {
let request;
beforeEach(async () => {
request = new Request('https://example.com');
fetchMock.mockResolvedValueOnce(
new Response('', {
headers: {
'cache-control': 's-maxage=1800, stale-while-revalidate=86400',
},
})
);
await swr({
request,
event: mockFetchEvent(),
});
});
// it('calls fetch as expected', () => {
// const [request] = fetchMock.mock.calls[0];
// expect(fetchMock).toHaveBeenCalledTimes(1);
// expect(request.url).toBe(`https://example.com/?t=${Date.now()}`);
// });
it('does cache the response', () => {
expect(cachesMock.put).toHaveBeenCalled();
});
});
const mockFetchEvent = () =>
(({
waitUntil: jest.fn(),
} as unknown) as FetchEvent);
const deferred = () => {
let resolve;
let reject;
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return {
promise,
resolve,
reject,
};
};
@josephglanville
Copy link

@wilsonpage I'm interested in using some of your implementation in our codebase, would it be possible for you to confirm a license the code could be used under? i.e (MIT, BSD, Apache, etc)

@wilsonpage
Copy link
Author

@wilsonpage I'm interested in using some of your implementation in our codebase, would it be possible for you to confirm a license the code could be used under? i.e (MIT, BSD, Apache, etc)

Haha! Yeah feel free to use whatever :) Shall we say it's 'MIT'? 🤷

@josephglanville
Copy link

MIT is great. Will add attribution for anything I use, thanks!

@neenhouse
Copy link

Haha! Yeah feel free to use whatever :) Shall we say it's 'MIT'? 🤷

👏 👏

@ysm-dev
Copy link

ysm-dev commented Apr 28, 2023

This works great with worker + hono + trpc!

Thanks 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment