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,
};
};
@Schachte
Copy link

@josephglanville - DO centralizes the data to a single colo, so definitely could have performance concerns, but would solve the consistency problem. I'm working on a design to solve this issue, I'll post a repo here and open-source it if it looks good.

I think maybe a mix of using KV + a blob store like GCS/S3 in conjunction with Cloudflare edge cache could help mimick SWR fairly nicely. They can race each other during the fetch. If the blob store loses, it'll asynchronously fetch and populate the edge cache, etc.

@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