Help needed tweaking Worker for multiple hostnames

Below is a Cloudflare worker that I’m using which caches WordPress HTML files and can handle purging as well using HTTP headers.

It works great however, I need it to be able to cache and purge any hostname. A the moment it creates a single key in Cloudflare KV called ‘html_cache_version’ and everytime a change happens in WordPress the version number changes. However the issue is, if 1 WordPress site makes a change, it purges cache for everyone.
Can someone help me turn html_cache_version into a hostname? to be able to cache and purge for each hostname.

// 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');
		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 && response) {
		const options = getResponseOptions(response);
		const cookieHeader = request.headers.get('cookie');
		let bypassCookies = DEFAULT_BYPASS_COOKIES;
		if (options) {
			bypassCookies = options.bypassCookies;
		}
		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' && accept && accept.indexOf('text/html') >= 0) {
		// 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);
			if (cachedResponse) {
				// Copy Response object so that we can edit headers.
				cachedResponse = new Response(cachedResponse.body, cachedResponse);

				// Check to see if the response needs to be bypassed because of a cookie
				bypassCache = shouldBypassEdgeCache(request, 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 = new URL(request.url);
    // Include the hostname in the cache key query parameter to differentiate cache entries by domain.
    let hostname = cacheUrl.hostname;
    if (cacheUrl.search) {
        cacheUrl.search += '&';
    } else {
        cacheUrl.search = '?';
    }
    cacheUrl.search += `cf_edge_cache_ver=${cacheVer}&hostname=${hostname}`;
    return new Request(cacheUrl.toString());
}
1 Like