Skip to content

Instantly share code, notes, and snippets.

@bhubbard
Last active July 28, 2025 01:39
Show Gist options
  • Save bhubbard/a46825bfa16f84a99308961ce41282b7 to your computer and use it in GitHub Desktop.
Save bhubbard/a46825bfa16f84a99308961ce41282b7 to your computer and use it in GitHub Desktop.
<?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 );
}
}
}
**
* 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