Last active
July 28, 2025 01:39
-
-
Save bhubbard/a46825bfa16f84a99308961ce41282b7 to your computer and use it in GitHub Desktop.
This file contains hidden or 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
<?php | |
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init, WordPress.WP.AlternativeFunctions.curl_curl_exec, WordPress.WP.AlternativeFunctions.curl_curl_setopt, WordPress.WP.AlternativeFunctions.curl_curl_close, WordPress.WP.AlternativeFunctions.curl_curl_error | |
/** | |
* Cloudflare KV Object Cache | |
* | |
* Drop-in replacement for the WordPress Object Cache. | |
* | |
* File: object-cache.php | |
* Directory: /wp-content/ | |
* | |
* @package WordPress | |
* @subpackage Caching | |
*/ | |
// Exit if accessed directly. | |
if ( ! defined( 'ABSPATH' ) ) { | |
exit; | |
} | |
// --- Configuration --- | |
// These values MUST be configured in your wp-config.php file for security. | |
// define( 'CF_KV_WORKER_URL', 'https://your-worker-name.your-account.workers.dev' ); | |
// define( 'CF_KV_AUTH_TOKEN', 'YOUR_SUPER_SECRET_TOKEN_HERE' ); | |
// define( 'CF_KV_NAMESPACE_ID', 'YOUR_CLOUDFLARE_KV_NAMESPACE_ID' ); // Optional, but good practice | |
// --------------------- | |
/** | |
* Global cache variables. | |
* | |
* @global array $wp_object_cache | |
*/ | |
global $wp_object_cache; | |
/** | |
* Main cache-handling function. | |
* | |
* @param string $action The action to perform (get, set, delete, flush). | |
* @param string $key The cache key. | |
* @param mixed $data The data to store. | |
* @param string $group The cache group. | |
* @param int $expire Expiration time in seconds. | |
* @return mixed The result of the cache operation. | |
*/ | |
function cf_kv_cache_handler( $action, $key, $data = '', $group = 'default', $expire = 0 ) { | |
// Ensure configuration is defined. | |
if ( ! defined( 'CF_KV_WORKER_URL' ) || ! defined( 'CF_KV_AUTH_TOKEN' ) ) { | |
// Fallback to error or a no-op if not configured, to avoid breaking the site. | |
// In a real scenario, you might log this error. | |
return false; | |
} | |
$url = trailingslashit( CF_KV_WORKER_URL ); | |
$headers = [ | |
'Content-Type: application/json', | |
'X-Auth-Token: ' . CF_KV_AUTH_TOKEN, | |
]; | |
$body = [ | |
'key' => $key, | |
'group' => $group, | |
]; | |
$method = 'POST'; // Most requests will be POST | |
switch ( $action ) { | |
case 'get': | |
$url .= 'get'; | |
break; | |
case 'set': | |
$url .= 'set'; | |
$body['value'] = maybe_serialize( $data ); | |
$body['expiration'] = $expire; | |
break; | |
case 'delete': | |
$url .= 'delete'; | |
break; | |
case 'flush': | |
$url .= 'flush'; | |
$method = 'DELETE'; | |
break; | |
default: | |
return false; | |
} | |
// Use cURL for the request. | |
$ch = curl_init(); | |
curl_setopt( $ch, CURLOPT_URL, $url ); | |
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 ); | |
curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, $method ); | |
curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $body ) ); | |
curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); | |
// Set a reasonable timeout to prevent long waits. | |
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 2 ); | |
curl_setopt( $ch, CURLOPT_TIMEOUT, 3 ); | |
$result = curl_exec( $ch ); | |
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); | |
curl_close( $ch ); | |
if ( $http_code !== 200 ) { | |
return false; // Request failed. | |
} | |
$response = json_decode( $result, true ); | |
if ( $action === 'get' ) { | |
return isset( $response['value'] ) ? maybe_unserialize( $response['value'] ) : false; | |
} | |
return isset( $response['success'] ) ? $response['success'] : false; | |
} | |
// --- WordPress Object Cache API Implementation --- | |
function wp_cache_init() { | |
global $wp_object_cache; | |
$wp_object_cache = new WP_Object_Cache(); | |
} | |
function wp_cache_add( $key, $data, $group = '', $expire = 0 ) { | |
global $wp_object_cache; | |
return $wp_object_cache->add( $key, $data, $group, $expire ); | |
} | |
function wp_cache_close() { | |
return true; | |
} | |
function wp_cache_decr( $key, $offset = 1, $group = '' ) { | |
global $wp_object_cache; | |
return $wp_object_cache->decr( $key, $offset, $group ); | |
} | |
function wp_cache_delete( $key, $group = '' ) { | |
global $wp_object_cache; | |
return $wp_object_cache->delete( $key, $group ); | |
} | |
function wp_cache_flush() { | |
// This is a powerful operation, ensure it's what you want. | |
// It will trigger the 'flush' action in the handler. | |
return cf_kv_cache_handler('flush', ''); | |
} | |
function wp_cache_get( $key, $group = '', $force = false, &$found = null ) { | |
global $wp_object_cache; | |
return $wp_object_cache->get( $key, $group, $force, $found ); | |
} | |
function wp_cache_incr( $key, $offset = 1, $group = '' ) { | |
global $wp_object_cache; | |
return $wp_object_cache->incr( $key, $offset, $group ); | |
} | |
function wp_cache_replace( $key, $data, $group = '', $expire = 0 ) { | |
global $wp_object_cache; | |
return $wp_object_cache->replace( $key, $data, $group, $expire ); | |
} | |
function wp_cache_set( $key, $data, $group = '', $expire = 0 ) { | |
global $wp_object_cache; | |
return $wp_object_cache->set( $key, $data, $group, $expire ); | |
} | |
function wp_cache_switch_to_blog( $blog_id ) { | |
global $wp_object_cache; | |
$wp_object_cache->switch_to_blog( $blog_id ); | |
} | |
function wp_cache_add_global_groups( $groups ) { | |
global $wp_object_cache; | |
$wp_object_cache->add_global_groups( $groups ); | |
} | |
function wp_cache_add_non_persistent_groups( $groups ) { | |
// For this implementation, we treat all groups as persistent via KV. | |
// This function can be a no-op. | |
} | |
// --- WP_Object_Cache Class --- | |
class WP_Object_Cache { | |
public $cache = []; // Local request cache to avoid multiple remote calls for the same key in one request. | |
public $global_groups = []; | |
public $blog_prefix; | |
public function __construct() { | |
global $blog_id; | |
$this->blog_prefix = is_multisite() ? $blog_id . ':' : ''; | |
} | |
private function get_key( $key, $group = 'default' ) { | |
if ( empty( $group ) ) { | |
$group = 'default'; | |
} | |
return $this->blog_prefix . $group . ':' . $key; | |
} | |
public function get( $key, $group = 'default', $force = false, &$found = null ) { | |
$cache_key = $this->get_key( $key, $group ); | |
// Check local request cache first. | |
if ( ! $force && isset( $this->cache[ $cache_key ] ) ) { | |
$found = true; | |
return is_object( $this->cache[ $cache_key ] ) ? clone $this->cache[ $cache_key ] : $this->cache[ $cache_key ]; | |
} | |
$value = cf_kv_cache_handler( 'get', $cache_key, '', $group ); | |
if ( $value !== false ) { | |
$found = true; | |
$this->cache[ $cache_key ] = $value; // Store in local request cache. | |
return is_object( $value ) ? clone $value : $value; | |
} | |
$found = false; | |
return false; | |
} | |
public function set( $key, $data, $group = 'default', $expire = 0 ) { | |
$cache_key = $this->get_key( $key, $group ); | |
// Store a copy in the local request cache. | |
$this->cache[ $cache_key ] = is_object( $data ) ? clone $data : $data; | |
return cf_kv_cache_handler( 'set', $cache_key, $data, $group, (int) $expire ); | |
} | |
public function add( $key, $data, $group = 'default', $expire = 0 ) { | |
// The 'add' logic (only set if not exists) is best handled by the worker if needed. | |
// For simplicity, we'll treat it like 'set'. A more robust solution would add a check. | |
return $this->set( $key, $data, $group, $expire ); | |
} | |
public function delete( $key, $group = 'default' ) { | |
$cache_key = $this->get_key( $key, $group ); | |
unset( $this->cache[ $cache_key ] ); // Remove from local cache. | |
return cf_kv_cache_handler( 'delete', $cache_key, '', $group ); | |
} | |
public function incr( $key, $offset = 1, $group = 'default' ) { | |
$value = $this->get( $key, $group, false, $found ); | |
if ( ! $found || ! is_numeric( $value ) ) { | |
$value = 0; | |
} | |
$value += $offset; | |
if ( $value < 0 ) { | |
$value = 0; | |
} | |
return $this->set( $key, $value, $group ) ? $value : false; | |
} | |
public function decr( $key, $offset = 1, $group = 'default' ) { | |
return $this->incr( $key, -$offset, $group ); | |
} | |
public function replace( $key, $data, $group = 'default', $expire = 0 ) { | |
// Similar to 'add', the 'replace' logic (only set if exists) is best handled by the worker. | |
// We will treat it like 'set' for simplicity. | |
return $this->set( $key, $data, $group, $expire ); | |
} | |
public function switch_to_blog( $blog_id ) { | |
$this->blog_prefix = is_multisite() ? $blog_id . ':' : ''; | |
} | |
public function add_global_groups( $groups ) { | |
if ( is_array( $groups ) ) { | |
$this->global_groups = array_merge( $this->global_groups, $groups ); | |
$this->global_groups = array_unique( $this->global_groups ); | |
} | |
} | |
} |
This file contains hidden or 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
** | |
* Cloudflare Worker to act as a secure gateway for a WordPress Object Cache using KV. | |
* | |
* Environment variables (secrets) that need to be set in the Worker settings: | |
* - AUTH_TOKEN: The secret token that your WordPress site will use to authenticate. | |
* - KV_CACHE: The binding to your KV namespace. | |
*/ | |
export default { | |
async fetch(request, env, ctx) { | |
// --- Security Check --- | |
// Ensure the request is coming from our WordPress site. | |
const authToken = request.headers.get('X-Auth-Token'); | |
if (authToken !== env.AUTH_TOKEN) { | |
return new Response('Unauthorized', { status: 401 }); | |
} | |
// --- Routing --- | |
const url = new URL(request.url); | |
const path = url.pathname; | |
if (request.method === 'POST' && path.endsWith('/get')) { | |
return handleGet(request, env); | |
} | |
if (request.method === 'POST' && path.endsWith('/set')) { | |
return handleSet(request, env); | |
} | |
if (request.method === 'POST' && path.endsWith('/delete')) { | |
return handleDelete(request, env); | |
} | |
if (request.method === 'DELETE' && path.endsWith('/flush')) { | |
// Note: Flush is a DELETE request for semantic correctness. | |
return handleFlush(request, env); | |
} | |
return new Response('Not Found', { status: 404 }); | |
}, | |
}; | |
/** | |
* Handles retrieving a value from the KV store. | |
* @param {Request} request The incoming request. | |
* @param {object} env The environment bindings. | |
* @returns {Response} | |
*/ | |
async function handleGet(request, env) { | |
try { | |
const { key } = await request.json(); | |
if (!key) { | |
return new Response('Missing key', { status: 400 }); | |
} | |
const value = await env.KV_CACHE.get(key); | |
if (value === null) { | |
return new Response(JSON.stringify({ success: false, value: null }), { | |
status: 404, | |
headers: { 'Content-Type': 'application/json' }, | |
}); | |
} | |
return new Response(JSON.stringify({ success: true, value: value }), { | |
headers: { 'Content-Type': 'application/json' }, | |
}); | |
} catch (e) { | |
return new Response(e.message, { status: 500 }); | |
} | |
} | |
/** | |
* Handles setting a value in the KV store. | |
* @param {Request} request The incoming request. | |
* @param {object} env The environment bindings. | |
* @returns {Response} | |
*/ | |
async function handleSet(request, env) { | |
try { | |
const { key, value, expiration } = await request.json(); | |
if (!key || value === undefined) { | |
return new Response('Missing key or value', { status: 400 }); | |
} | |
const options = {}; | |
// KV expiration TTL must be an integer and at least 60 seconds. | |
if (expiration && parseInt(expiration, 10) >= 60) { | |
options.expirationTtl = parseInt(expiration, 10); | |
} | |
await env.KV_CACHE.put(key, value, options); | |
return new Response(JSON.stringify({ success: true }), { | |
headers: { 'Content-Type': 'application/json' }, | |
}); | |
} catch (e) { | |
return new Response(e.message, { status: 500 }); | |
} | |
} | |
/** | |
* Handles deleting a value from the KV store. | |
* @param {Request} request The incoming request. | |
* @param {object} env The environment bindings. | |
* @returns {Response} | |
*/ | |
async function handleDelete(request, env) { | |
try { | |
const { key } = await request.json(); | |
if (!key) { | |
return new Response('Missing key', { status: 400 }); | |
} | |
await env.KV_CACHE.delete(key); | |
return new Response(JSON.stringify({ success: true }), { | |
headers: { 'Content-Type': 'application/json' }, | |
}); | |
} catch (e) { | |
return new Response(e.message, { status: 500 }); | |
} | |
} | |
/** | |
* Handles flushing the entire KV namespace. THIS IS A DESTRUCTIVE OPERATION. | |
* @param {Request} request The incoming request. | |
* @param {object} env The environment bindings. | |
* @returns {Response} | |
*/ | |
async function handleFlush(request, env) { | |
try { | |
let hasMore = true; | |
let cursor = undefined; | |
while(hasMore) { | |
const { keys, list_complete, cursor: nextCursor } = await env.KV_CACHE.list({ cursor }); | |
const keyNames = keys.map(k => k.name); | |
// Fire off all delete operations concurrently for speed. | |
await Promise.all(keyNames.map(name => env.KV_CACHE.delete(name))); | |
hasMore = !list_complete; | |
cursor = nextCursor; | |
} | |
return new Response(JSON.stringify({ success: true, message: "Flush complete." }), { | |
headers: { 'Content-Type': 'application/json' }, | |
}); | |
} catch (e) { | |
return new Response(e.message, { status: 500 }); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment