Last active
June 29, 2024 12:17
-
-
Save markomitranic/aca18fee327242a09da48125f026e7f6 to your computer and use it in GitHub Desktop.
CloudFlare edge worker for WP
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// IMPORTANT: Either A Key/Value Namespace must be bound to this worker script | |
// using the variable name EDGE_CACHE. or the API parameters below should be | |
// configured. KV is recommended if possible since it can purge just the HTML | |
// instead of the full cache. | |
// API settings if KV isn't being used | |
const CLOUDFLARE_API = { | |
email: "", // From https://dash.cloudflare.com/profile | |
key: "", // Global API Key from https://dash.cloudflare.com/profile | |
zone: "" // "Zone ID" from the API section of the dashboard overview page https://dash.cloudflare.com/ | |
}; | |
// Default cookie prefixes for bypass | |
const DEFAULT_BYPASS_COOKIES = [ | |
"wp-", | |
"wordpress", | |
"comment_", | |
"woocommerce_" | |
]; | |
/** | |
* Main worker entry point. | |
*/ | |
addEventListener("fetch", event => { | |
const request = event.request; | |
let upstreamCache = request.headers.get('x-HTML-Edge-Cache'); | |
// Only process requests if KV store is set up and there is no | |
// HTML edge cache in front of this worker (only the outermost cache | |
// should handle HTML caching in case there are varying levels of support). | |
let configured = false; | |
if (typeof EDGE_CACHE !== 'undefined') { | |
configured = true; | |
} else if (CLOUDFLARE_API.email.length && CLOUDFLARE_API.key.length && CLOUDFLARE_API.zone.length) { | |
configured = true; | |
} | |
// Bypass processing of image requests (for everything except Firefox which doesn't use image/*) | |
const accept = request.headers.get('Accept'); | |
let isImage = false; | |
if (accept && (accept.indexOf('image/*') !== -1)) { | |
isImage = true; | |
} | |
if (configured && !isImage && upstreamCache === null) { | |
event.passThroughOnException(); | |
event.respondWith(processRequest(request, event)); | |
} | |
}); | |
/** | |
* Process every request coming through to add the edge-cache header, | |
* watch for purge responses and possibly cache HTML GET requests. | |
* | |
* @param {Request} originalRequest - Original request | |
* @param {Event} event - Original event (for additional async waiting) | |
*/ | |
async function processRequest(originalRequest, event) { | |
let cfCacheStatus = null; | |
const accept = originalRequest.headers.get('Accept'); | |
const isHTML = (accept && accept.indexOf('text/html') >= 0); | |
let {response, cacheVer, status, bypassCache} = await getCachedResponse(originalRequest); | |
if (response === null) { | |
// Clone the request, add the edge-cache header and send it through. | |
let request = new Request(originalRequest); | |
request.headers.set('x-HTML-Edge-Cache', 'supports=cache|purgeall|bypass-cookies'); | |
if (bypassCache) { | |
request = createBypassCacheRequest(request); | |
} | |
response = await fetch(request); | |
if (response) { | |
const options = getResponseOptions(response); | |
if (options && options.purge) { | |
await purgeCache(cacheVer, event); | |
status += ', Purged'; | |
} | |
bypassCache = bypassCache || shouldBypassEdgeCache(request, response); | |
if ((!options || options.cache) && isHTML && | |
originalRequest.method === 'GET' && response.status === 200 && | |
!bypassCache) { | |
status += await cacheResponse(cacheVer, originalRequest, response, event); | |
} | |
} | |
} else { | |
// If the origin didn't send the control header we will send the cached response but update | |
// the cached copy asynchronously (stale-while-revalidate). This commonly happens with | |
// a server-side disk cache that serves the HTML directly from disk. | |
cfCacheStatus = 'HIT'; | |
if (originalRequest.method === 'GET' && response.status === 200 && isHTML) { | |
bypassCache = bypassCache || shouldBypassEdgeCache(originalRequest, response); | |
if (!bypassCache) { | |
const options = getResponseOptions(response); | |
if (!options) { | |
status += ', Refreshed'; | |
event.waitUntil(updateCache(originalRequest, cacheVer, event)); | |
} | |
} | |
} | |
} | |
if (response && status !== null && originalRequest.method === 'GET' && response.status === 200 && isHTML) { | |
response = new Response(response.body, response); | |
response.headers.set('x-HTML-Edge-Cache-Status', status); | |
if (cacheVer !== null) { | |
response.headers.set('x-HTML-Edge-Cache-Version', cacheVer.toString()); | |
} | |
if (cfCacheStatus) { | |
response.headers.set('CF-Cache-Status', cfCacheStatus); | |
} | |
} | |
return response; | |
} | |
/** | |
* Determine if the cache should be bypassed for the given request/response pair. | |
* Specifically, if the request includes a cookie that the response flags for bypass. | |
* Can be used on cache lookups to determine if the request needs to go to the origin and | |
* origin responses to determine if they should be written to cache. | |
* @param {Request} request - Request | |
* @param {Response} response - Response | |
* @returns {bool} true if the cache should be bypassed | |
*/ | |
function shouldBypassEdgeCache(request, response) { | |
let bypassCache = false; | |
if (request) { | |
let bypassCookies = DEFAULT_BYPASS_COOKIES; | |
if (response) { | |
const options = getResponseOptions(response); | |
if (options) { | |
bypassCookies = options.bypassCookies; | |
} | |
} | |
let cookieHeader = request.headers.get('cookie'); | |
if (cookieHeader && cookieHeader.length && bypassCookies.length) { | |
const cookies = cookieHeader.split(';'); | |
for (let cookie of cookies) { | |
// See if the cookie starts with any of the logged-in user prefixes | |
for (let prefix of bypassCookies) { | |
if (cookie.trim().startsWith(prefix)) { | |
bypassCache = true; | |
break; | |
} | |
} | |
if (bypassCache) { | |
break; | |
} | |
} | |
} | |
} | |
return bypassCache; | |
} | |
const CACHE_HEADERS = ['Cache-Control', 'Expires', 'Pragma']; | |
/** | |
* Check for cached HTML GET requests. | |
* | |
* @param {Request} request - Original request | |
*/ | |
async function getCachedResponse(request) { | |
let response = null; | |
let cacheVer = null; | |
let bypassCache = false; | |
let status = 'Miss'; | |
// Only check for HTML GET requests (saves on reading from KV unnecessarily) | |
// and not when there are cache-control headers on the request (refresh) | |
const accept = request.headers.get('Accept'); | |
const cacheControl = request.headers.get('Cache-Control'); | |
let noCache = false; | |
if (cacheControl && cacheControl.indexOf('no-cache') !== -1) { | |
noCache = true; | |
status = 'Bypass for Reload'; | |
} | |
if (!noCache && request.method === 'GET') { | |
// Build the versioned URL for checking the cache | |
cacheVer = await GetCurrentCacheVersion(cacheVer); | |
const cacheKeyRequest = GenerateCacheRequest(request, cacheVer); | |
// See if there is a request match in the cache | |
try { | |
let cache = caches.default; | |
let cachedResponse = await cache.match(cacheKeyRequest); | |
// Check to see if the response needs to be bypassed because of a cookie | |
bypassCache = shouldBypassEdgeCache(request, cachedResponse); | |
if (cachedResponse) { | |
// Copy Response object so that we can edit headers. | |
cachedResponse = new Response(cachedResponse.body, cachedResponse); | |
// Copy the original cache headers back and clean up any control headers | |
if (bypassCache) { | |
status = 'Bypass Cookie'; | |
} else { | |
status = 'Hit'; | |
cachedResponse.headers.delete('Cache-Control'); | |
cachedResponse.headers.delete('x-HTML-Edge-Cache-Status'); | |
for (header of CACHE_HEADERS) { | |
let value = cachedResponse.headers.get('x-HTML-Edge-Cache-Header-' + header); | |
if (value) { | |
cachedResponse.headers.delete('x-HTML-Edge-Cache-Header-' + header); | |
cachedResponse.headers.set(header, value); | |
} | |
} | |
response = cachedResponse; | |
} | |
} else { | |
status = 'Miss'; | |
} | |
} catch (err) { | |
// Send the exception back in the response header for debugging | |
status = "Cache Read Exception: " + err.message; | |
} | |
} | |
return {response, cacheVer, status, bypassCache}; | |
} | |
/** | |
* Asynchronously purge the HTML cache. | |
* @param {Int} cacheVer - Current cache version (if retrieved) | |
* @param {Event} event - Original event | |
*/ | |
async function purgeCache(cacheVer, event) { | |
if (typeof EDGE_CACHE !== 'undefined') { | |
// Purge the KV cache by bumping the version number | |
cacheVer = await GetCurrentCacheVersion(cacheVer); | |
cacheVer++; | |
event.waitUntil(EDGE_CACHE.put('html_cache_version', cacheVer.toString())); | |
} else { | |
// Purge everything using the API | |
const url = "https://api.cloudflare.com/client/v4/zones/" + CLOUDFLARE_API.zone + "/purge_cache"; | |
event.waitUntil(fetch(url,{ | |
method: 'POST', | |
headers: {'X-Auth-Email': CLOUDFLARE_API.email, | |
'X-Auth-Key': CLOUDFLARE_API.key, | |
'Content-Type': 'application/json'}, | |
body: JSON.stringify({purge_everything: true}) | |
})); | |
} | |
} | |
/** | |
* Update the cached copy of the given page | |
* @param {Request} originalRequest - Original Request | |
* @param {String} cacheVer - Cache Version | |
* @param {EVent} event - Original event | |
*/ | |
async function updateCache(originalRequest, cacheVer, event) { | |
// Clone the request, add the edge-cache header and send it through. | |
let request = new Request(originalRequest); | |
request.headers.set('x-HTML-Edge-Cache', 'supports=cache|purgeall|bypass-cookies'); | |
response = await fetch(request); | |
if (response) { | |
status = ': Fetched'; | |
const options = getResponseOptions(response); | |
if (options && options.purge) { | |
await purgeCache(cacheVer, event); | |
} | |
let bypassCache = shouldBypassEdgeCache(request, response); | |
if ((!options || options.cache) && !bypassCache) { | |
await cacheResponse(cacheVer, originalRequest, response, event); | |
} | |
} | |
} | |
/** | |
* Cache the returned content (but only if it was a successful GET request) | |
* | |
* @param {Int} cacheVer - Current cache version (if already retrieved) | |
* @param {Request} request - Original Request | |
* @param {Response} originalResponse - Response to (maybe) cache | |
* @param {Event} event - Original event | |
* @returns {bool} true if the response was cached | |
*/ | |
async function cacheResponse(cacheVer, request, originalResponse, event) { | |
let status = ""; | |
const accept = request.headers.get('Accept'); | |
if (request.method === 'GET' && originalResponse.status === 200 && accept && accept.indexOf('text/html') >= 0) { | |
cacheVer = await GetCurrentCacheVersion(cacheVer); | |
const cacheKeyRequest = GenerateCacheRequest(request, cacheVer); | |
try { | |
// Move the cache headers out of the way so the response can actually be cached. | |
// First clone the response so there is a parallel body stream and then | |
// create a new response object based on the clone that we can edit. | |
let cache = caches.default; | |
let clonedResponse = originalResponse.clone(); | |
let response = new Response(clonedResponse.body, clonedResponse); | |
for (header of CACHE_HEADERS) { | |
let value = response.headers.get(header); | |
if (value) { | |
response.headers.delete(header); | |
response.headers.set('x-HTML-Edge-Cache-Header-' + header, value); | |
} | |
} | |
response.headers.delete('Set-Cookie'); | |
response.headers.set('Cache-Control', 'public; max-age=315360000'); | |
event.waitUntil(cache.put(cacheKeyRequest, response)); | |
status = ", Cached"; | |
} catch (err) { | |
// status = ", Cache Write Exception: " + err.message; | |
} | |
} | |
return status; | |
} | |
/****************************************************************************** | |
* Utility Functions | |
*****************************************************************************/ | |
/** | |
* Parse the commands from the x-HTML-Edge-Cache response header. | |
* @param {Response} response - HTTP response from the origin. | |
* @returns {*} Parsed commands | |
*/ | |
function getResponseOptions(response) { | |
let options = null; | |
let header = response.headers.get('x-HTML-Edge-Cache'); | |
if (header) { | |
options = { | |
purge: false, | |
cache: false, | |
bypassCookies: [] | |
}; | |
let commands = header.split(','); | |
for (let command of commands) { | |
if (command.trim() === 'purgeall') { | |
options.purge = true; | |
} else if (command.trim() === 'cache') { | |
options.cache = true; | |
} else if (command.trim().startsWith('bypass-cookies')) { | |
let separator = command.indexOf('='); | |
if (separator >= 0) { | |
let cookies = command.substr(separator + 1).split('|'); | |
for (let cookie of cookies) { | |
cookie = cookie.trim(); | |
if (cookie.length) { | |
options.bypassCookies.push(cookie); | |
} | |
} | |
} | |
} | |
} | |
} | |
return options; | |
} | |
/** | |
* Retrieve the current cache version from KV | |
* @param {Int} cacheVer - Current cache version value if set. | |
* @returns {Int} The current cache version. | |
*/ | |
async function GetCurrentCacheVersion(cacheVer) { | |
if (cacheVer === null) { | |
if (typeof EDGE_CACHE !== 'undefined') { | |
cacheVer = await EDGE_CACHE.get('html_cache_version'); | |
if (cacheVer === null) { | |
// Uninitialized - first time through, initialize KV with a value | |
// Blocking but should only happen immediately after worker activation. | |
cacheVer = 0; | |
await EDGE_CACHE.put('html_cache_version', cacheVer.toString()); | |
} else { | |
cacheVer = parseInt(cacheVer); | |
} | |
} else { | |
cacheVer = -1; | |
} | |
} | |
return cacheVer; | |
} | |
/** | |
* Generate the versioned Request object to use for cache operations. | |
* @param {Request} request - Base request | |
* @param {Int} cacheVer - Current Cache version (must be set) | |
* @returns {Request} Versioned request object | |
*/ | |
function GenerateCacheRequest(request, cacheVer) { | |
let cacheUrl = request.url; | |
if (cacheUrl.indexOf('?') >= 0) { | |
cacheUrl += '&'; | |
} else { | |
cacheUrl += '?'; | |
} | |
cacheUrl += 'cf_edge_cache_ver=' + cacheVer; | |
return new Request(cacheUrl); | |
} | |
function createBypassCacheRequest(request) { | |
const req = request instanceof Request ? request : new Request(request); | |
let url = new URL(req.url); | |
let query_string = url.search; | |
let search_params = new URLSearchParams(query_string); | |
search_params.set('wdtcack', '1'); | |
url.search = search_params.toString(); | |
let new_url = url.toString(); | |
return new Request(new_url, req) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment