Skip to content

Commit d334fa1

Browse files
committed
Add recipe for common caching with support for the wagtail-ab-testing package
1 parent 9c63331 commit d334fa1

3 files changed

Lines changed: 383 additions & 2 deletions

File tree

.eslintrc.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ module.exports = {
66
"no-use-before-define": 0
77
},
88
globals: {
9-
"HTMLRewriter": "readonly"
9+
"HTMLRewriter": "readonly",
10+
"WAGTAIL_AB_TESTING_WORKER_TOKEN": "readonly"
1011
}
1112
};

common-caching-with-ab-testing.js

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
// This is a Cloudflare Worker script that implements caching with support for A/B testing.
2+
// It is based on the common-caching.js script, with additional logic to handle A/B testing scenarios, based on:
3+
// https://github.com/wagtail-nest/wagtail-ab-testing/blob/204493c2a78131acf52d5feda3ec40425cc0b58a/README.md#running-ab-tests-on-a-site-that-uses-cloudflare-caching
4+
//
5+
// A path can only be identified as having an A/B test after it has been requested.
6+
// If it is an A/B test, ensure it never gets put into the cache via responseIsCachable, and the common caching
7+
// logic will miss on the cache and fetch the A/B test from the origin.
8+
9+
// ** Set WAGTAIL_AB_TESTING_WORKER_TOKEN as a global variable in Cloudflare Workers dashboard **
10+
// This should match the token on your Django settings
11+
12+
const AB_TEST_HEADER = "X-WagtailAbTesting-Test";
13+
14+
async function fetchOrigin(request, env) {
15+
if (request.method === "GET") {
16+
const newRequest = new Request(request, {
17+
headers: {
18+
...request.headers,
19+
Authorization: `Token ${env.WAGTAIL_AB_TESTING_WORKER_TOKEN}`,
20+
"X-Requested-With": "WagtailAbTestingWorker",
21+
},
22+
});
23+
24+
const response = await fetch(newRequest);
25+
26+
// If there is a test running at the URL, the worker would return
27+
// a JSON response containing both versions of the page. Also, it
28+
// returns the test ID in the X-WagtailAbTesting-Test header.
29+
const testId = response.headers.get(AB_TEST_HEADER);
30+
if (testId) {
31+
// Participants of a test would have a cookie that tells us which
32+
// version of the page being tested on that they should see
33+
// If they don't have this cookie, serve a random version
34+
const versionCookieName = `abtesting-${testId}-version`;
35+
const cookie = request.headers.get("cookie");
36+
let version;
37+
if (cookie && cookie.includes(`${versionCookieName}=control`)) {
38+
version = "control";
39+
} else if (cookie && cookie.includes(`${versionCookieName}=variant`)) {
40+
version = "variant";
41+
} else if (Math.random() < 0.5) {
42+
version = "control";
43+
} else {
44+
version = "variant";
45+
}
46+
47+
const jsonResponse = await response.json();
48+
return new Response(jsonResponse[version], {
49+
headers: {
50+
...response.headers,
51+
"Content-Type": "text/html",
52+
},
53+
});
54+
}
55+
56+
return response;
57+
}
58+
59+
return fetch(request);
60+
}
61+
62+
// ----------------------------------------------------
63+
//
64+
// Lightly modified common-caching.js script, based on:
65+
// https://github.com/torchbox/cloudflare-recipes/blob/cdafd8dbbb0475c25806fb32d3c6c24145924596/common-caching.js
66+
//
67+
// fetchOrigin replaces calls to fetch to ensure A/B test responses are handled correctly
68+
// responseIsCachable has an additional clause to prevent caching of A/B tests
69+
// ----------------------------------------------------
70+
71+
// NOTE: A 'Cache Level' page rule set to 'Cache Everything' will
72+
// prevent private cookie cache skipping from working, as it is
73+
// applied after this worker runs.
74+
75+
// When any cookie in this list is present in the request, cache will be skipped
76+
const PRIVATE_COOKIES = ["sessionid"];
77+
78+
// Cookies to include in the cache key
79+
const VARY_COOKIES = [];
80+
81+
// Request headers to include in the cache key.
82+
// Note: Do not add `cookie` to this list!
83+
const VARY_HEADERS = [
84+
"X-Requested-With",
85+
86+
// HTMX
87+
"HX-Boosted",
88+
"HX-Current-URL",
89+
"HX-History-Restore-Request",
90+
"HX-Prompt",
91+
"HX-Request",
92+
"HX-Target",
93+
"HX-Trigger-Name",
94+
"HX-Trigger",
95+
];
96+
97+
// These querystring keys are stripped from the request as they are generally not
98+
// needed by the origin.
99+
const STRIP_QUERYSTRING_KEYS = [
100+
// UTM
101+
"utm_id",
102+
"utm_source",
103+
"utm_campaign",
104+
"utm_medium",
105+
"utm_term",
106+
"utm_content",
107+
"utm_source_platform",
108+
"utm_creative_format",
109+
"utm_marketing_tactic",
110+
111+
"gclid",
112+
"wbraid",
113+
"gbraid",
114+
"fbclid",
115+
"dm_i", // DotDigital
116+
"msclkid",
117+
"al_applink_data", // Meta outbound app links
118+
119+
// https://docs.flying-press.com/cache/ignore-query-strings
120+
"age-verified",
121+
"ao_noptimize",
122+
"usqp",
123+
"cn-reloaded",
124+
"sscid",
125+
"ef_id",
126+
"_bta_tid",
127+
"_bta_c",
128+
"fb_action_ids",
129+
"fb_action_types",
130+
"fb_source",
131+
"_ga",
132+
"adid",
133+
"_gl",
134+
"gclsrc",
135+
"gdfms",
136+
"gdftrk",
137+
"gdffi",
138+
"_ke",
139+
"trk_contact",
140+
"trk_msg",
141+
"trk_module",
142+
"trk_sid",
143+
"mc_cid",
144+
"mc_eid",
145+
"mkwid",
146+
"pcrid",
147+
"mtm_source",
148+
"mtm_medium",
149+
"mtm_campaign",
150+
"mtm_keyword",
151+
"mtm_cid",
152+
"mtm_content",
153+
"epik",
154+
"pp",
155+
"pk_source",
156+
"pk_medium",
157+
"pk_campaign",
158+
"pk_keyword",
159+
"pk_cid",
160+
"pk_content",
161+
"redirect_log_mongo_id",
162+
"redirect_mongo_id",
163+
"sb_referer_host",
164+
];
165+
166+
// If this is true, the querystring keys stripped from the request will be
167+
// addeed to any Location header served by a redirect.
168+
const REPLACE_STRIPPED_QUERYSTRING_ON_REDIRECT_LOCATION = false;
169+
170+
// If this is true, querystring key are stripped if they have no value eg. ?foo
171+
// Disabled by default, but highly recommended
172+
const STRIP_VALUELESS_QUERYSTRING_KEYS = false;
173+
174+
// Only these status codes should be considered cacheable
175+
// (from https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.4)
176+
const CACHABLE_HTTP_STATUS_CODES = [200, 203, 206, 300, 301, 410];
177+
178+
export default {
179+
async fetch(originalRequest, env, ctx) {
180+
const cache = caches.default;
181+
// eslint-disable-next-line prefer-const
182+
const [request, strippedParams] = stripQuerystring(originalRequest);
183+
184+
if (!requestIsCachable(request)) {
185+
// If the request isn't cacheable, return a Response directly from the origin.
186+
return fetchOrigin(request, env);
187+
}
188+
189+
const cachingRequest = getCachingRequest(request);
190+
let response = await cache.match(cachingRequest);
191+
192+
if (!response) {
193+
// If we didn't get a response from the cache, fetch one from the origin
194+
// and put it in the cache.
195+
response = await fetchOrigin(request, env);
196+
if (responseIsCachable(response)) {
197+
ctx.waitUntil(cache.put(cachingRequest, response.clone()));
198+
}
199+
}
200+
201+
if (REPLACE_STRIPPED_QUERYSTRING_ON_REDIRECT_LOCATION) {
202+
response = replaceStrippedQsOnRedirectResponse(response, strippedParams);
203+
}
204+
205+
return response;
206+
},
207+
};
208+
209+
/*
210+
* Cacheability Utilities
211+
*/
212+
function requestIsCachable(request) {
213+
/*
214+
* Given a Request, determine if it should be cached.
215+
* Currently the only factor here is whether a private cookie is present.
216+
*/
217+
return !hasPrivateCookie(request);
218+
}
219+
220+
function responseIsCachable(response) {
221+
/*
222+
* Given a Response, determine if it should be cached.
223+
* Factors here are whether the status code is cachable, and whether it is an A/B test response (uncached if so).
224+
*/
225+
return (
226+
CACHABLE_HTTP_STATUS_CODES.includes(response.status) &&
227+
!response.headers.has(AB_TEST_HEADER)
228+
);
229+
}
230+
231+
function getCachingRequest(request) {
232+
/**
233+
* Create a new request for use as a cache key.
234+
*
235+
* Note: Modifications to this request are not sent upstream.
236+
*/
237+
238+
const cookies = getCookies(request);
239+
240+
const requestURL = new URL(request.url);
241+
242+
// Include specified cookies in cache key
243+
VARY_COOKIES.forEach((cookieName) =>
244+
requestURL.searchParams.set(
245+
`cookie-${cookieName}`,
246+
cookies[cookieName] || ""
247+
)
248+
);
249+
250+
// Include specified headers in cache key
251+
VARY_HEADERS.forEach((headerName) =>
252+
requestURL.searchParams.set(
253+
`header-${headerName}`,
254+
request.headers.get(headerName) || ""
255+
)
256+
);
257+
258+
return new Request(requestURL, request);
259+
}
260+
261+
/*
262+
* Request Utilities
263+
*/
264+
function stripQuerystring(request) {
265+
/**
266+
* Given a Request, return a new Request with the ignored or blank querystring keys stripped out,
267+
* along with an object representing the stripped values.
268+
*/
269+
const url = new URL(request.url);
270+
271+
const stripKeys = STRIP_QUERYSTRING_KEYS.filter((v) =>
272+
url.searchParams.has(v)
273+
);
274+
275+
const strippedParams = {};
276+
277+
if (stripKeys.length) {
278+
stripKeys.reduce((acc, key) => {
279+
acc[key] = url.searchParams.getAll(key);
280+
url.searchParams.delete(key);
281+
return acc;
282+
}, strippedParams);
283+
}
284+
285+
if (STRIP_VALUELESS_QUERYSTRING_KEYS) {
286+
// Strip query params without values to avoid unnecessary cache misses
287+
[...url.searchParams.entries()].forEach(([key, value]) => {
288+
if (!value) {
289+
url.searchParams.delete(key);
290+
strippedParams[key] = "";
291+
}
292+
});
293+
}
294+
295+
return [new Request(url, request), strippedParams];
296+
}
297+
298+
function hasPrivateCookie(request) {
299+
/*
300+
* Given a Request, determine if one of the 'private' cookies are present.
301+
*/
302+
const allCookies = getCookies(request);
303+
304+
// Check if any of the private cookies are present and have a non-empty value
305+
return PRIVATE_COOKIES.some(
306+
(cookieName) => cookieName in allCookies && allCookies[cookieName]
307+
);
308+
}
309+
310+
function getCookies(request) {
311+
/*
312+
* Extract the cookies from a given request
313+
*/
314+
const cookieHeader = request.headers.get("Cookie");
315+
if (!cookieHeader) {
316+
return {};
317+
}
318+
319+
return cookieHeader.split(";").reduce((cookieMap, cookieString) => {
320+
const [cookieKey, cookieValue] = cookieString.split("=");
321+
return { ...cookieMap, [cookieKey.trim()]: (cookieValue || "").trim() };
322+
}, {});
323+
}
324+
325+
/**
326+
* Response Utilities
327+
*/
328+
329+
function replaceStrippedQsOnRedirectResponse(response, strippedParams) {
330+
/**
331+
* Given an existing Response, and an object of stripped querystring keys,
332+
* determine if the response is a redirect.
333+
* If it is, add the stripped querystrings to the location header.
334+
* This allows us to persist tracking querystrings (like UTM) over redirects.
335+
*/
336+
337+
if ([301, 302].includes(response.status)) {
338+
const redirectResponse = new Response(response.body, response);
339+
const locationHeaderValue = redirectResponse.headers.get("location");
340+
let locationUrl;
341+
342+
if (!locationHeaderValue) {
343+
return redirectResponse;
344+
}
345+
346+
const isAbsolute = isUrlAbsolute(locationHeaderValue);
347+
348+
if (!isAbsolute) {
349+
// If the Location URL isn't absolute, we need to provide a Host so we can use
350+
// a URL object.
351+
locationUrl = new URL(locationHeaderValue, "http://www.example.com");
352+
} else {
353+
locationUrl = new URL(locationHeaderValue);
354+
}
355+
356+
Object.entries(strippedParams).forEach(([key, value]) =>
357+
locationUrl.searchParams.append(key, value)
358+
);
359+
360+
let newLocation;
361+
362+
if (isAbsolute) {
363+
newLocation = locationUrl.toString();
364+
} else {
365+
newLocation = `${locationUrl.pathname}${locationUrl.search}`;
366+
}
367+
368+
redirectResponse.headers.set("location", newLocation);
369+
return redirectResponse;
370+
}
371+
372+
return response;
373+
}
374+
375+
/**
376+
* URL Utilities
377+
*/
378+
function isUrlAbsolute(url) {
379+
return url.indexOf("://") > 0 || url.indexOf("//") === 0;
380+
}

common-caching.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ function getCookies(request) {
249249

250250
return cookieHeader.split(";").reduce((cookieMap, cookieString) => {
251251
const [cookieKey, cookieValue] = cookieString.split("=");
252-
return { ...cookieMap, [cookieKey.trim()]: cookieValue.trim() };
252+
return { ...cookieMap, [cookieKey.trim()]: (cookieValue || "").trim() };
253253
}, {});
254254
}
255255

0 commit comments

Comments
 (0)