aRustyDev / aRustyDev/mdbook-htmx

docs(examples): Cloudflare Workers KV Caching

Open
#39 0 comments 0 reactions 1 assignee Claimed by @aRustyDev View on GitHub
documentation
Dominant language
Rust
Stars
0
Forks
1
PR merge metrics
No merged PRs in 30d

Description

# Cloudflare Workers KV Caching

Implement edge caching for mdbook-htmx using Cloudflare Workers KV.

## Overview

Use KV to cache:
- Rendered page content
- Search index segments
- Session data
- API responses

This reduces D1 reads and improves response times.

## Architecture

```
Request → Worker → KV Cache → (miss) → Origin/D1

(hit)

Response
```

## KV Namespaces

Create separate namespaces for different cache types:

```bash
# Page content cache
wrangler kv:namespace create PAGES_CACHE
wrangler kv:namespace create PAGES_CACHE --preview

# Session cache
wrangler kv:namespace create SESSION_CACHE
wrangler kv:namespace create SESSION_CACHE --preview

# Search cache
wrangler kv:namespace create SEARCH_CACHE
wrangler kv:namespace create SEARCH_CACHE --preview
```

## wrangler.toml

```toml
name = "docs-with-cache"
main = "src/index.ts"
compatibility_date = "2024-01-01"

# KV Namespaces
[[kv_namespaces]]
binding = "PAGES_CACHE"
id = "xxxxx"
preview_id = "yyyyy"

[[kv_namespaces]]
binding = "SESSION_CACHE"
id = "xxxxx"
preview_id = "yyyyy"

[[kv_namespaces]]
binding = "SEARCH_CACHE"
id = "xxxxx"
preview_id = "yyyyy"

# Cache TTLs (in seconds)
[vars]
CACHE_TTL_PAGES = "3600" # 1 hour
CACHE_TTL_SESSIONS = "300" # 5 minutes
CACHE_TTL_SEARCH = "1800" # 30 minutes
```

## Cache Implementation

### src/cache.ts

```typescript
import { Env } from './types';

// Page cache with stale-while-revalidate pattern
export class PageCache {
private kv: KVNamespace;
private ttl: number;

constructor(kv: KVNamespace, ttl: number) {
this.kv = kv;
this.ttl = ttl;
}

async get(path: string, scope?: string): Promise {
const key = this.getKey(path, scope);
const entry = await this.kv.get(key, 'json');

if (!entry) return null;

const cached = entry as CacheEntry;

// Check if stale (but still usable)
if (Date.now() > cached.staleAt) {
cached.isStale = true;
}

return cached;
}

async set(path: string, content: string, options: CacheOptions = {}): Promise {
const key = this.getKey(path, options.scope);
const now = Date.now();

const entry: CacheEntry = {
content,
contentType: options.contentType || 'text/html',
etag: this.generateEtag(content),
cachedAt: now,
staleAt: now + (this.ttl * 1000),
expiresAt: now + (this.ttl * 2 * 1000), // Double TTL for stale-while-revalidate
scope: options.scope,
version: options.version,
};

await this.kv.put(key, JSON.stringify(entry), {
expirationTtl: this.ttl * 2,
metadata: {
contentType: entry.contentType,
etag: entry.etag,
},
});
}

async invalidate(path: string, scope?: string): Promise {
const key = this.getKey(path, scope);
await this.kv.delete(key);
}

async invalidatePrefix(prefix: string): Promise {
const list = await this.kv.list({ prefix: `page:${prefix}` });
await Promise.all(list.keys.map(k => this.kv.delete(k.name)));
}

private getKey(path: string, scope?: string): string {
return scope ? `page:${scope}:${path}` : `page:${path}`;
}

private generateEtag(content: string): string {
// Simple hash for ETag
let hash = 0;
for (let i = 0; i < content.length; i++) {
hash = ((hash << 5) - hash) + content.charCodeAt(i);
hash |= 0;
}
return `"${Math.abs(hash).toString(36)}"`;
}
}

// Session cache with shorter TTL
export class SessionCache {
private kv: KVNamespace;
private ttl: number;

constructor(kv: KVNamespace, ttl: number) {
this.kv = kv;
this.ttl = ttl;
}

async get(sessionId: string): Promise {
return await this.kv.get(`session:${sessionId}`, 'json');
}

async set(sessionId: string, data: SessionData): Promise {
await this.kv.put(`session:${sessionId}`, JSON.stringify(data), {
expirationTtl: this.ttl,
});
}

async delete(sessionId: string): Promise {
await this.kv.delete(`session:${sessionId}`);
}
}

// Search results cache
export class SearchCache {
private kv: KVNamespace;
private ttl: number;

constructor(kv: KVNamespace, ttl: number) {
this.kv = kv;
this.ttl = ttl;
}

async get(query: string, scope?: string): Promise {
const key = this.getKey(query, scope);
return await this.kv.get(key, 'json');
}

async set(query: string, results: SearchResults, scope?: string): Promise {
const key = this.getKey(query, scope);
await this.kv.put(key, JSON.stringify(results), {
expirationTtl: this.ttl,
});
}

private getKey(query: string, scope?: string): string {
const normalized = query.toLowerCase().trim();
const hash = this.hashQuery(normalized);
return scope ? `search:${scope}:${hash}` : `search:${hash}`;
}

private hashQuery(query: string): string {
let hash = 0;
for (let i = 0; i < query.length; i++) {
hash = ((hash << 5) - hash) + query.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash).toString(36);
}
}

interface CacheEntry {
content: string;
contentType: string;
etag: string;
cachedAt: number;
staleAt: number;
expiresAt: number;
scope?: string;
version?: string;
isStale?: boolean;
}

interface CacheOptions {
scope?: string;
contentType?: string;
version?: string;
}

interface SessionData {
userId: string;
email: string;
roles: string[];
expiresAt: string;
}

interface SearchResults {
query: string;
hits: Array<{
path: string;
title: string;
excerpt: string;
score: number;
}>;
totalHits: number;
cachedAt: number;
}
```

## Worker Integration

### src/index.ts

```typescript
import { PageCache, SessionCache, SearchCache } from './cache';
import { Env } from './types';

export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise {
const url = new URL(request.url);
const path = url.pathname;

// Initialize caches
const pageCache = new PageCache(env.PAGES_CACHE, parseInt(env.CACHE_TTL_PAGES));
const sessionCache = new SessionCache(env.SESSION_CACHE, parseInt(env.CACHE_TTL_SESSIONS));
const searchCache = new SearchCache(env.SEARCH_CACHE, parseInt(env.CACHE_TTL_SEARCH));

// Handle search with caching
if (path === '/search') {
return handleSearch(request, env, searchCache);
}

// Try to get from cache first
const scope = url.searchParams.get('scope') || undefined;
const cached = await pageCache.get(path, scope);

if (cached && !cached.isStale) {
// Fresh cache hit
return new Response(cached.content, {
headers: {
'Content-Type': cached.contentType,
'ETag': cached.etag,
'X-Cache': 'HIT',
'Cache-Control': 'public, max-age=0, must-revalidate',
},
});
}

// Check If-None-Match for 304 response
const ifNoneMatch = request.headers.get('If-None-Match');
if (cached && ifNoneMatch === cached.etag) {
return new Response(null, {
status: 304,
headers: {
'ETag': cached.etag,
'X-Cache': 'HIT',
},
});
}

// Stale-while-revalidate: return stale content and refresh in background
if (cached?.isStale) {
ctx.waitUntil(refreshCache(path, scope, pageCache, env));
return new Response(cached.content, {
headers: {
'Content-Type': cached.contentType,
'ETag': cached.etag,
'X-Cache': 'STALE',
'Cache-Control': 'public, max-age=0, must-revalidate',
},
});
}

// Cache miss - fetch from origin
const response = await fetch(request);

if (response.ok && response.headers.get('Content-Type')?.includes('text/html')) {
const content = await response.text();

// Cache in background
ctx.waitUntil(
pageCache.set(path, content, {
scope,
contentType: 'text/html; charset=utf-8',
})
);

return new Response(content, {
status: response.status,
headers: {
...Object.fromEntries(response.headers),
'X-Cache': 'MISS',
},
});
}

return response;
}
};

async function refreshCache(
path: string,
scope: string | undefined,
cache: PageCache,
env: Env
): Promise {
try {
const response = await fetch(`${env.ORIGIN_URL}${path}${scope ? `?scope=${scope}` : ''}`);
if (response.ok) {
const content = await response.text();
await cache.set(path, content, { scope });
}
} catch (error) {
console.error('Cache refresh failed:', error);
}
}

async function handleSearch(
request: Request,
env: Env,
cache: SearchCache
): Promise {
const url = new URL(request.url);
const query = url.searchParams.get('q') || '';
const scope = url.searchParams.get('scope') || undefined;

// Check cache
const cached = await cache.get(query, scope);
if (cached) {
return Response.json(cached, {
headers: { 'X-Cache': 'HIT' },
});
}

// Forward to search service
const searchUrl = new URL(`${env.SEARCH_URL}/search`);
searchUrl.searchParams.set('q', query);
if (scope) searchUrl.searchParams.set('scope', scope);

const response = await fetch(searchUrl.toString());
const results = await response.json();

// Cache results
await cache.set(query, results, scope);

return Response.json(results, {
headers: { 'X-Cache': 'MISS' },
});
}
```

## Cache Invalidation

### Webhook for content updates

```typescript
// src/webhooks.ts

export async function handleCacheInvalidation(
request: Request,
env: Env
): Promise {
// Verify webhook signature
const signature = request.headers.get('X-Webhook-Signature');
if (!await verifySignature(request, signature, env.WEBHOOK_SECRET)) {
return new Response('Unauthorized', { status: 401 });
}

const body = await request.json();
const cache = new PageCache(env.PAGES_CACHE, 0);

switch (body.event) {
case 'content.updated':
// Invalidate specific page
await cache.invalidate(body.path);
break;

case 'content.bulk_updated':
// Invalidate by prefix
await cache.invalidatePrefix(body.prefix);
break;

case 'deploy':
// Invalidate everything (use sparingly)
const list = await env.PAGES_CACHE.list({ prefix: 'page:' });
await Promise.all(list.keys.map(k => env.PAGES_CACHE.delete(k.name)));
break;
}

return Response.json({ success: true });
}
```

## Monitoring Cache Performance

```typescript
// src/metrics.ts

interface CacheMetrics {
hits: number;
misses: number;
stale: number;
errors: number;
}

export class CacheMetricsCollector {
private metrics: CacheMetrics = {
hits: 0,
misses: 0,
stale: 0,
errors: 0,
};

recordHit() { this.metrics.hits++; }
recordMiss() { this.metrics.misses++; }
recordStale() { this.metrics.stale++; }
recordError() { this.metrics.errors++; }

getHitRate(): number {
const total = this.metrics.hits + this.metrics.misses;
return total > 0 ? this.metrics.hits / total : 0;
}

async flush(env: Env): Promise {
// Send to analytics
await env.ANALYTICS.writeDataPoint({
blobs: ['cache_metrics'],
doubles: [
this.metrics.hits,
this.metrics.misses,
this.metrics.stale,
this.getHitRate(),
],
});
}
}
```

## Cost Optimization

### KV Pricing

| Operation | Free Tier | Paid |
|-----------|-----------|------|
| Reads | 100K/day | $0.50/1M |
| Writes | 1K/day | $5.00/1M |
| Deletes | 1K/day | $5.00/1M |
| Storage | 1 GB | $0.50/GB |

### Optimization Strategies

1. **Batch writes**: Group cache invalidations
2. **Appropriate TTLs**: Longer TTL = fewer writes
3. **Selective caching**: Only cache popular pages
4. **Compression**: Compress large content before caching

```typescript
// Compress before caching
import { compress, decompress } from './compression';

async set(key: string, content: string): Promise {
const compressed = await compress(content);
await this.kv.put(key, compressed, {
metadata: { compressed: true },
});
}

async get(key: string): Promise {
const result = await this.kv.getWithMetadata(key, 'arrayBuffer');
if (!result.value) return null;

if (result.metadata?.compressed) {
return await decompress(result.value);
}
return new TextDecoder().decode(result.value);
}
```

## Next Steps

- Set up [D1 for authentication](./cf-workers-d1.md)
- Add [Meilisearch for full-text search](./meilisearch-cf-tunnel.md)
- Configure [Kubernetes deployment](./k8s-deployment.md)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.