🎯 Ejemplos recomendados
Balanced sample collections from various categories for you to explore
Ejemplos Cloudflare Workers
Ejemplos de edge computing con Cloudflare Workers incluyendo proxies API, procesamiento de imágenes y aplicaciones edge-first
💻 Worker Proxy API javascript
🟢 simple
⭐⭐
Proxy API edge con caching, rate limiting y transformación de solicitudes
⏱️ 15 min
🏷️ cloudflare, workers, api, proxy, caching
Prerequisites:
Cloudflare account, Workers enabled, Basic JavaScript knowledge
// Cloudflare Workers API Proxy
// src/index.js - Main worker file
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const pathname = url.pathname;
// Handle different routes
if (pathname === '/') {
return new Response('Hello from Cloudflare Workers API Proxy!', {
headers: {
'Content-Type': 'text/plain',
'Cache-Control': 'public, max-age=86400'
}
});
}
if (pathname === '/health') {
return Response.json({
status: 'healthy',
timestamp: new Date().toISOString(),
region: request.cf.colo,
ip: request.cf.colo
});
}
// API proxy functionality
if (pathname.startsWith('/api/')) {
return handleAPIProxy(request, env);
}
// Default response
return new Response('Not Found', { status: 404 });
}
};
async function handleAPIProxy(request, env) {
const url = new URL(request.url);
const targetPath = url.pathname.replace('/api', '');
const targetURL = `${env.API_BASE_URL}${targetPath}${url.search}`;
// Rate limiting check
const clientIP = request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For');
const rateLimitKey = `rate_limit:${clientIP}`;
const { success: rateLimitResult } = await env.RATE_LIMITER.limit({
key: rateLimitKey,
limit: 100, // 100 requests per minute
ttl: 60, // per minute
});
if (!rateLimitResult) {
return new Response('Rate limit exceeded', {
status: 429,
headers: {
'Retry-After': '60',
'X-RateLimit-Limit': '100',
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': Math.ceil(Date.now() / 1000) + 60
}
});
}
try {
// Check cache first
const cacheKey = new Request(targetURL, {
method: request.method,
headers: request.headers
});
const cache = caches.default;
let response = await cache.match(cacheKey);
if (!response) {
// Cache miss - make request to target API
const init = {
method: request.method,
headers: {},
body: request.body
};
// Copy headers (except CF-specific headers)
request.headers.forEach((value, key) => {
if (!key.startsWith('CF-')) {
init.headers[key] = value;
}
});
// Add custom headers
init.headers['X-Forwarded-For'] = clientIP;
init.headers['X-Proxy-By'] = 'Cloudflare Workers';
const targetResponse = await fetch(targetURL, init);
// Transform response
const responseBody = await targetResponse.text();
const transformedBody = transformResponse(responseBody, targetURL);
response = new Response(transformedBody, {
status: targetResponse.status,
statusText: targetResponse.statusText,
headers: targetResponse.headers
});
// Add custom headers to response
response.headers.set('X-Cache-Status', 'MISS');
response.headers.set('X-Proxy-Timestamp', new Date().toISOString());
response.headers.set('X-Edge-Location', request.cf.colo);
// Cache successful responses
if (targetResponse.ok) {
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
} else {
// Cache hit
response = new Response(response.body, response);
response.headers.set('X-Cache-Status', 'HIT');
}
// Add rate limit headers
const remaining = await env.RATE_LIMITER.remaining({ key: rateLimitKey });
response.headers.set('X-RateLimit-Limit', '100');
response.headers.set('X-RateLimit-Remaining', remaining.toString());
response.headers.set('X-RateLimit-Reset', Math.ceil(Date.now() / 1000) + 60);
return response;
} catch (error) {
console.error('Proxy error:', error);
return Response.json({
error: 'Proxy request failed',
message: error.message,
timestamp: new Date().toISOString()
}, { status: 502 });
}
}
function transformResponse(responseBody, targetURL) {
try {
// Try to parse as JSON and transform
const data = JSON.parse(responseBody);
// Add proxy metadata
if (Array.isArray(data)) {
return JSON.stringify({
data: data,
meta: {
proxied: true,
source: targetURL,
timestamp: new Date().toISOString(),
total: data.length
}
});
} else if (typeof data === 'object') {
return JSON.stringify({
...data,
meta: {
proxied: true,
source: targetURL,
timestamp: new Date().toISOString()
}
});
}
} catch (e) {
// Not JSON, return as-is
}
return responseBody;
}
// wrangler.toml - Configuration file
/*
name = "api-proxy-worker"
main = "src/index.js"
compatibility_date = "2023-10-30"
# Environment variables
[env.production]
API_BASE_URL = "https://api.example.com"
# KV namespace for rate limiting
[[kv_namespaces]]
binding = "RATE_LIMITER"
id = "your-kv-namespace-id"
preview_id = "your-preview-kv-namespace-id"
*/
💻 Worker Procesamiento de Imágenes typescript
🟡 intermediate
⭐⭐⭐
Redimensionamiento, optimización y transformación de imágenes en tiempo real en el edge
⏱️ 30 min
🏷️ cloudflare, workers, image, processing, transformation
Prerequisites:
Cloudflare account, Workers and R2 enabled, TypeScript knowledge
// Cloudflare Workers Image Processing
// src/index.ts - TypeScript implementation with advanced image processing
import { ImageResponse } from 'cloudflare-workers-experimental';
interface ImageOptions {
width?: number;
height?: number;
quality?: number;
format?: 'webp' | 'jpeg' | 'png' | 'avif';
fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
gravity?: 'auto' | 'center' | 'top' | 'bottom' | 'left' | 'right';
blur?: number;
sharpen?: number;
brightness?: number;
contrast?: number;
saturation?: number;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const pathname = url.pathname;
// Handle image processing requests
if (pathname.startsWith('/process/')) {
return handleImageProcessing(request, env);
}
// Handle direct image uploads
if (pathname === '/upload' && request.method === 'POST') {
return handleImageUpload(request, env);
}
// Handle health check
if (pathname === '/health') {
return Response.json({
status: 'healthy',
timestamp: new Date().toISOString(),
features: {
imageProcessing: true,
transformations: ['resize', 'optimize', 'format-convert'],
supportedFormats: ['webp', 'jpeg', 'png', 'avif']
}
});
}
// Serve processing UI
if (pathname === '/') {
return new Response(getProcessingUI(), {
headers: { 'Content-Type': 'text/html' }
});
}
return new Response('Not Found', { status: 404 });
}
};
async function handleImageProcessing(request: Request, env: Env): Promise<Response> {
try {
const url = new URL(request.url);
const imagePath = url.pathname.replace('/process/', '');
const imageUrl = decodeURIComponent(imagePath);
// Parse query parameters
const options: ImageOptions = {
width: parseInt(url.searchParams.get('width') || '0'),
height: parseInt(url.searchParams.get('height') || '0'),
quality: parseInt(url.searchParams.get('quality') || '80'),
format: url.searchParams.get('format') as any || 'auto',
fit: url.searchParams.get('fit') as any || 'cover',
gravity: url.searchParams.get('gravity') as any || 'auto',
blur: parseFloat(url.searchParams.get('blur') || '0'),
sharpen: parseFloat(url.searchParams.get('sharpen') || '0'),
brightness: parseFloat(url.searchParams.get('brightness') || '1'),
contrast: parseFloat(url.searchParams.get('contrast') || '1'),
saturation: parseFloat(url.searchParams.get('saturation') || '1')
};
// Validate parameters
if (options.width && (options.width < 1 || options.width > 4096)) {
return Response.json({ error: 'Width must be between 1 and 4096' }, { status: 400 });
}
if (options.height && (options.height < 1 || options.height > 4096)) {
return Response.json({ error: 'Height must be between 1 and 4096' }, { status: 400 });
}
if (options.quality && (options.quality < 1 || options.quality > 100)) {
return Response.json({ error: 'Quality must be between 1 and 100' }, { status: 400 });
}
// Fetch original image
let imageResponse: Response;
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
// Fetch from external URL
imageResponse = await fetch(imageUrl);
} else {
// Fetch from R2 storage
const objectKey = imageUrl.startsWith('/') ? imageUrl.slice(1) : imageUrl;
imageResponse = await env.IMAGE_BUCKET.get(objectKey);
if (!imageResponse) {
return Response.json({ error: 'Image not found' }, { status: 404 });
}
}
if (!imageResponse.ok) {
return Response.json({ error: 'Failed to fetch image' }, { status: 502 });
}
const originalImage = await imageResponse.arrayBuffer();
// Detect original format
const originalFormat = detectImageFormat(originalImage);
// Determine target format
const targetFormat = options.format === 'auto' ?
getOptimalFormat(request.headers.get('Accept'), originalFormat) :
options.format;
// Process image using ImageMagick (if available) or simple resizing
const processedImage = await processImage(originalImage, {
...options,
format: targetFormat,
originalFormat
});
// Generate cache-friendly filename
const processedUrl = generateProcessedUrl(imageUrl, options);
// Store processed image in R2 for future use
ctx.waitUntil(env.IMAGE_BUCKET.put(processedUrl, processedImage, {
httpMetadata: {
contentType: `image/${targetFormat}`,
cacheControl: 'public, max-age=31536000'
}
}));
// Return processed image
return new Response(processedImage, {
headers: {
'Content-Type': `image/${targetFormat}`,
'Cache-Control': 'public, max-age=31536000',
'X-Processed-By': 'Cloudflare Workers',
'X-Original-Format': originalFormat,
'X-Target-Format': targetFormat,
'X-Processing-Time': Date.now().toString()
}
});
} catch (error) {
console.error('Image processing error:', error);
return Response.json({
error: 'Image processing failed',
message: error.message
}, { status: 500 });
}
}
async function handleImageUpload(request: Request, env: Env): Promise<Response> {
try {
const formData = await request.formData();
const file = formData.get('image') as File;
if (!file) {
return Response.json({ error: 'No image file provided' }, { status: 400 });
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
return Response.json({ error: 'Invalid file type' }, { status: 400 });
}
// Validate file size (10MB limit)
if (file.size > 10 * 1024 * 1024) {
return Response.json({ error: 'File too large (max 10MB)' }, { status: 400 });
}
// Generate unique filename
const fileExtension = file.name.split('.').pop();
const uniqueId = crypto.randomUUID();
const filename = `${uniqueId}.${fileExtension}`;
// Store in R2
await env.IMAGE_BUCKET.put(filename, file.stream(), {
httpMetadata: {
contentType: file.type,
contentDisposition: `inline; filename="${file.name}"`,
cacheControl: 'public, max-age=31536000'
}
});
return Response.json({
success: true,
filename,
originalName: file.name,
size: file.size,
type: file.type,
processingUrl: `https://${request.headers.get('host')}/process/${filename}`
});
} catch (error) {
console.error('Upload error:', error);
return Response.json({
error: 'Upload failed',
message: error.message
}, { status: 500 });
}
}
function detectImageFormat(imageBuffer: ArrayBuffer): string {
const view = new Uint8Array(imageBuffer);
// Check magic numbers
if (view[0] === 0xFF && view[1] === 0xD8) return 'jpeg';
if (view[0] === 0x89 && view[1] === 0x50) return 'png';
if (view[0] === 0x52 && view[1] === 0x49) return 'webp';
if (view[0] === 0x47 && view[1] === 0x49) return 'gif';
return 'unknown';
}
function getOptimalFormat(acceptHeader: string | null, originalFormat: string): string {
if (!acceptHeader) return originalFormat;
// Priority order based on browser support
if (acceptHeader.includes('image/avif')) return 'avif';
if (acceptHeader.includes('image/webp')) return 'webp';
return originalFormat;
}
async function processImage(
imageBuffer: ArrayBuffer,
options: ImageOptions & { originalFormat: string }
): Promise<ArrayBuffer> {
// Simplified image processing
// In production, you might use @cloudflare/wrangler-image-tools or external API
const { Image } = await import('image-js');
try {
const image = await Image.load(imageBuffer);
// Apply transformations
if (options.width || options.height) {
const width = options.width || image.width;
const height = options.height || image.height;
// Resize with aspect ratio preservation
const aspectRatio = image.width / image.height;
const targetAspectRatio = width / height;
let cropWidth = image.width;
let cropHeight = image.height;
if (options.fit === 'cover') {
if (targetAspectRatio > aspectRatio) {
cropWidth = image.height * targetAspectRatio;
} else {
cropHeight = image.width / targetAspectRatio;
}
}
// Crop and resize
const cropX = (image.width - cropWidth) / 2;
const cropY = (image.height - cropHeight) / 2;
image.crop({
x: cropX,
y: cropY,
width: cropWidth,
height: cropHeight
});
image.resize({ width, height });
}
// Apply filters
if (options.brightness && options.brightness !== 1) {
image.adjustBrightness(options.brightness);
}
if (options.contrast && options.contrast !== 1) {
image.adjustContrast(options.contrast);
}
if (options.saturation && options.saturation !== 1) {
image.adjustSaturation(options.saturation);
}
// Convert format
const format = options.format || options.originalFormat;
let processedBuffer: ArrayBuffer;
switch (format) {
case 'jpeg':
processedBuffer = await image.encodeJPEG({ quality: options.quality });
break;
case 'png':
processedBuffer = await image.encodePNG();
break;
case 'webp':
processedBuffer = await image.encodeWebP({ quality: options.quality });
break;
case 'avif':
processedBuffer = await image.encodeAVIF({ quality: options.quality });
break;
default:
processedBuffer = imageBuffer;
}
return processedBuffer;
} catch (error) {
console.error('Image processing failed:', error);
return imageBuffer; // Return original if processing fails
}
}
function generateProcessedUrl(imageUrl: string, options: ImageOptions): string {
const params = new URLSearchParams();
Object.entries(options).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '' && value !== 0) {
params.set(key, value.toString());
}
});
const queryString = params.toString();
const baseName = imageUrl.includes('.') ? imageUrl.split('.')[0] : imageUrl;
const extension = imageUrl.includes('.') ? imageUrl.split('.').pop() : 'jpg';
return queryString ? `${baseName}?${queryString}.${extension}` : imageUrl;
}
function getProcessingUI(): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cloudflare Workers Image Processor</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
.container { background: #f5f5f5; padding: 20px; border-radius: 8px; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input, select { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
button { background: #007cba; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background: #005a87; }
.preview { margin-top: 20px; text-align: center; }
.preview img { max-width: 100%; height: auto; border: 1px solid #ddd; }
</style>
</head>
<body>
<div class="container">
<h1>🖼️ Cloudflare Workers Image Processor</h1>
<h2>Upload Image</h2>
<form id="uploadForm">
<div class="form-group">
<label for="imageFile">Choose image:</label>
<input type="file" id="imageFile" accept="image/*" required>
</div>
<button type="submit">Upload</button>
</form>
<div id="result"></div>
<h2>Process Existing Image</h2>
<form id="processForm">
<div class="form-group">
<label for="imageUrl">Image URL:</label>
<input type="url" id="imageUrl" placeholder="https://example.com/image.jpg" required>
</div>
<div class="form-group">
<label for="width">Width:</label>
<input type="number" id="width" min="1" max="4096" placeholder="800">
</div>
<div class="form-group">
<label for="height">Height:</label>
<input type="number" id="height" min="1" max="4096" placeholder="600">
</div>
<div class="form-group">
<label for="format">Format:</label>
<select id="format">
<option value="auto">Auto</option>
<option value="webp">WebP</option>
<option value="avif">AVIF</option>
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>
</select>
</div>
<div class="form-group">
<label for="quality">Quality:</label>
<input type="range" id="quality" min="1" max="100" value="80">
<span id="qualityValue">80</span>
</div>
<button type="submit">Process Image</button>
</form>
<div id="processedResult" class="preview"></div>
</div>
<script>
// Quality slider
const qualitySlider = document.getElementById('quality');
const qualityValue = document.getElementById('qualityValue');
qualitySlider.addEventListener('input', () => {
qualityValue.textContent = qualitySlider.value;
});
// Upload form
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData();
formData.append('image', document.getElementById('imageFile').files[0]);
try {
const response = await fetch('/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
document.getElementById('result').innerHTML =
`<div style="color: green;">
<strong>Upload successful!</strong><br>
Filename: ${result.filename}<br>
<a href="${result.processingUrl}">Process this image</a>
</div>`;
} else {
document.getElementById('result').innerHTML =
`<div style="color: red;">Error: ${result.error}</div>`;
}
} catch (error) {
document.getElementById('result').innerHTML =
`<div style="color: red;">Error: ${error.message}</div>`;
}
});
// Process form
document.getElementById('processForm').addEventListener('submit', async (e) => {
e.preventDefault();
const imageUrl = document.getElementById('imageUrl').value;
const width = document.getElementById('width').value;
const height = document.getElementById('height').value;
const format = document.getElementById('format').value;
const quality = document.getElementById('quality').value;
const processedUrl = new URL('/process/' + encodeURIComponent(imageUrl), window.location.origin);
if (width) processedUrl.searchParams.set('width', width);
if (height) processedUrl.searchParams.set('height', height);
if (format !== 'auto') processedUrl.searchParams.set('format', format);
processedUrl.searchParams.set('quality', quality);
document.getElementById('processedResult').innerHTML =
`
<h3>Processed Image:</h3>
<p>URL: <a href="${processedUrl}" target="_blank">${processedUrl}</a></p>
<img src="${processedUrl}" alt="Processed image" style="max-width: 100%; height: auto;">
`;
});
</script>
</body>
</html>
`;
}
// wrangler.toml
/*
name = "image-processor"
main = "src/index.ts"
compatibility_date = "2023-10-30"
# Bindings
[[r2_buckets]]
binding = "IMAGE_BUCKET"
bucket_name = "your-image-bucket"
# Dependencies
[build]
command = "npm install && npm run build"
[vars]
ENVIRONMENT = "production"
*/
💻 Worker Servidor WebSocket javascript
🟡 intermediate
⭐⭐⭐⭐
Servidor WebSocket en tiempo real con autenticación y gestión de salas
⏱️ 35 min
🏷️ cloudflare, workers, websocket, real-time, durable-objects
Prerequisites:
Cloudflare account, Workers and Durable Objects enabled, WebSocket knowledge
// Cloudflare Workers WebSocket Server
// src/index.js - Real-time WebSocket implementation with room management
// Durable Object for room management
export class ChatRoom {
constructor(state, env) {
this.state = state;
this.env = env;
this.sessions = new Map();
this.roomName = state.id;
this.connectedClients = 0;
// Load persisted data if exists
this.loadPersistedData();
}
// Load persisted room data
async loadPersistedData() {
try {
const data = await this.state.storage.get('roomData');
if (data) {
this.roomInfo = data;
console.log(`Loaded persisted data for room ${this.roomName}`);
} else {
this.roomInfo = {
name: this.roomName,
createdAt: Date.now(),
messageCount: 0,
maxClients: 10
};
}
} catch (error) {
console.error('Error loading persisted data:', error);
this.roomInfo = {
name: this.roomName,
createdAt: Date.now(),
messageCount: 0,
maxClients: 10
};
}
}
// Persist room data
async persistData() {
try {
await this.state.storage.put('roomData', this.roomInfo);
console.log(`Persisted data for room ${this.roomName}`);
} catch (error) {
console.error('Error persisting data:', error);
}
}
async fetch(request) {
const url = new URL(request.url);
const pathname = url.pathname;
if (pathname.startsWith('/socket')) {
return this.handleWebSocket(request);
}
// Handle room management API
if (pathname === '/info') {
return new Response(JSON.stringify({
room: this.roomInfo,
connectedClients: this.connectedClients,
capacity: this.roomInfo.maxClients - this.connectedClients
}), {
headers: { 'Content-Type': 'application/json' }
});
}
return new Response('Not Found', { status: 404 });
}
async handleWebSocket(request) {
// Check room capacity
if (this.connectedClients >= this.roomInfo.maxClients) {
return new Response('Room is full', { status: 429 });
}
// Upgrade WebSocket connection
const pair = new WebSocketPair();
const [client, server] = pair;
await this.handleSession(server, request);
return new Response(null, {
status: 101,
webSocket: client
});
}
async handleSession(websocket, request) {
websocket.accept();
// Generate session ID
const sessionId = crypto.randomUUID();
// Extract client info from request headers
const userAgent = request.headers.get('User-Agent') || 'Unknown';
const ip = request.headers.get('CF-Connecting-IP') || 'Unknown';
const country = request.cf?.country || 'Unknown';
// Create session object
const session = {
id: sessionId,
websocket,
ip,
userAgent,
country,
joinedAt: Date.now(),
lastPing: Date.now(),
nickname: `User-${sessionId.substring(0, 8)}`,
isAuthenticated: false,
room: this.roomName
};
this.sessions.set(sessionId, session);
this.connectedClients++;
// Send welcome message
this.sendToSession(sessionId, {
type: 'welcome',
data: {
sessionId,
roomName: this.roomName,
connectedClients: this.connectedClients,
message: 'Connected to chat room'
}
});
// Notify other clients about new user
this.broadcast({
type: 'userJoined',
data: {
user: {
id: sessionId,
nickname: session.nickname,
country
},
totalClients: this.connectedClients
}
}, sessionId);
// Handle WebSocket messages
websocket.addEventListener('message', (event) => {
this.handleMessage(sessionId, event.data);
});
// Handle WebSocket close
websocket.addEventListener('close', () => {
this.handleDisconnect(sessionId);
});
// Setup ping/pong for connection health
const pingInterval = setInterval(() => {
if (Date.now() - session.lastPing > 30000) { // 30 seconds timeout
this.handleDisconnect(sessionId);
clearInterval(pingInterval);
} else {
websocket.send(JSON.stringify({ type: 'ping' }));
}
}, 15000);
// Handle pong responses
websocket.addEventListener('message', (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'pong') {
session.lastPing = Date.now();
}
} catch (e) {
// Ignore parsing errors
}
});
// Store ping interval ID for cleanup
session.pingInterval = pingInterval;
console.log(`Client ${sessionId} connected to room ${this.roomName}`);
}
handleMessage(sessionId, data) {
const session = this.sessions.get(sessionId);
if (!session) return;
try {
const message = JSON.parse(data);
switch (message.type) {
case 'ping':
session.lastPing = Date.now();
break;
case 'authenticate':
this.handleAuthentication(sessionId, message.data);
break;
case 'setNickname':
this.handleNicknameChange(sessionId, message.data);
break;
case 'chatMessage':
this.handleChatMessage(sessionId, message.data);
break;
case 'privateMessage':
this.handlePrivateMessage(sessionId, message.data);
break;
case 'typing':
this.handleTypingIndicator(sessionId, message.data);
break;
default:
console.warn(`Unknown message type: ${message.type}`);
}
} catch (error) {
console.error('Error parsing message:', error);
this.sendToSession(sessionId, {
type: 'error',
data: { message: 'Invalid message format' }
});
}
}
handleAuthentication(sessionId, data) {
const session = this.sessions.get(sessionId);
if (!session) return;
// Simple token-based authentication (in production, use proper auth)
const token = data.token;
if (token && token.length > 10) {
session.isAuthenticated = true;
session.userType = 'authenticated';
this.sendToSession(sessionId, {
type: 'authSuccess',
data: { userType: 'authenticated' }
});
this.broadcast({
type: 'userAuthenticated',
data: {
user: {
id: sessionId,
nickname: session.nickname
}
}
});
}
}
handleNicknameChange(sessionId, data) {
const session = this.sessions.get(sessionId);
if (!session) return;
const newNickname = data.nickname?.trim();
if (newNickname && newNickname.length > 0 && newNickname.length <= 20) {
const oldNickname = session.nickname;
session.nickname = newNickname;
this.sendToSession(sessionId, {
type: 'nicknameChanged',
data: { newNickname }
});
this.broadcast({
type: 'userNicknameChanged',
data: {
userId: sessionId,
oldNickname,
newNickname
}
});
}
}
handleChatMessage(sessionId, data) {
const session = this.sessions.get(sessionId);
if (!session) return;
const message = data.message?.trim();
if (!message || message.length > 500) {
this.sendToSession(sessionId, {
type: 'error',
data: { message: 'Message must be 1-500 characters' }
});
return;
}
const chatMessage = {
id: crypto.randomUUID(),
sessionId,
nickname: session.nickname,
message,
timestamp: Date.now(),
type: session.isAuthenticated ? 'authenticated' : 'guest'
};
// Broadcast to all clients in the room
this.broadcast({
type: 'chatMessage',
data: chatMessage
});
// Update message count
this.roomInfo.messageCount++;
// Persist every 10 messages
if (this.roomInfo.messageCount % 10 === 0) {
this.persistData();
}
console.log(`Chat message in ${this.roomName}: ${session.nickname}: ${message}`);
}
handlePrivateMessage(sessionId, data) {
const session = this.sessions.get(sessionId);
if (!session) return;
const targetSessionId = data.targetSessionId;
const message = data.message?.trim();
if (!targetSessionId || !message) {
this.sendToSession(sessionId, {
type: 'error',
data: { message: 'Invalid private message format' }
});
return;
}
const targetSession = this.sessions.get(targetSessionId);
if (!targetSession) {
this.sendToSession(sessionId, {
type: 'error',
data: { message: 'Target user not found' }
});
return;
}
const privateMessage = {
id: crypto.randomUUID(),
fromSessionId: sessionId,
fromNickname: session.nickname,
toSessionId: targetSessionId,
message,
timestamp: Date.now(),
type: 'private'
};
// Send to target user
this.sendToSession(targetSessionId, {
type: 'privateMessage',
data: privateMessage
});
// Send confirmation to sender
this.sendToSession(sessionId, {
type: 'privateMessageSent',
data: {
...privateMessage,
deliveryStatus: 'sent'
}
});
}
handleTypingIndicator(sessionId, data) {
const session = this.sessions.get(sessionId);
if (!session) return;
const isTyping = data.isTyping;
this.broadcast({
type: 'userTyping',
data: {
userId: sessionId,
nickname: session.nickname,
isTyping
}
}, sessionId);
}
handleDisconnect(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return;
// Clear ping interval
if (session.pingInterval) {
clearInterval(session.pingInterval);
}
// Remove session
this.sessions.delete(sessionId);
this.connectedClients--;
// Notify other clients
this.broadcast({
type: 'userLeft',
data: {
user: {
id: sessionId,
nickname: session.nickname
},
totalClients: this.connectedClients
}
});
console.log(`Client ${sessionId} disconnected from room ${this.roomName}`);
// Persist data when room becomes empty
if (this.connectedClients === 0) {
this.persistData();
}
}
sendToSession(sessionId, message) {
const session = this.sessions.get(sessionId);
if (!session || !session.websocket) return;
try {
session.websocket.send(JSON.stringify(message));
} catch (error) {
console.error(`Error sending message to ${sessionId}:`, error);
this.handleDisconnect(sessionId);
}
}
broadcast(message, excludeSessionId = null) {
for (const [sessionId, session] of this.sessions) {
if (sessionId !== excludeSessionId) {
this.sendToSession(sessionId, message);
}
}
}
}
// Main worker entry point
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const pathname = url.pathname;
// Handle WebSocket connections to rooms
if (pathname.startsWith('/room/')) {
const roomId = pathname.replace('/room/', '');
// Get room Durable Object
const room = env.CHAT_ROOMS.get(env.CHAT_ROOMS.idFromName(roomId));
return room.fetch(request);
}
// Handle room creation
if (pathname === '/create-room' && request.method === 'POST') {
return handleCreateRoom(request, env);
}
// Handle room listing
if (pathname === '/list-rooms') {
return handleListRooms(env);
}
// Serve WebSocket test page
if (pathname === '/' || pathname === '/test.html') {
return new Response(getWebSocketTestPage(), {
headers: { 'Content-Type': 'text/html' }
});
}
return new Response('Not Found', { status: 404 });
}
});
async function handleCreateRoom(request, env) {
try {
const { roomName, maxClients = 10, isPrivate = false } = await request.json();
if (!roomName || roomName.trim().length === 0) {
return new Response(JSON.stringify({ error: 'Room name is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Validate room name
if (roomName.length > 50 || !/^[a-zA-Z0-9_-]+$/.test(roomName)) {
return new Response(JSON.stringify({
error: 'Room name must be 1-50 characters and contain only letters, numbers, underscores, and hyphens'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Create room
const roomId = env.CHAT_ROOMS.idFromName(roomName);
const room = env.CHAT_ROOMS.get(roomId);
// Store room metadata
await env.ROOM_METADATA.put(roomName, JSON.stringify({
name: roomName,
maxClients,
isPrivate,
createdAt: Date.now(),
createdBy: request.headers.get('CF-Connecting-IP') || 'Unknown'
}));
return new Response(JSON.stringify({
success: true,
roomName,
roomId: roomId.toString(),
websocketUrl: `/room/${roomName}`
}), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
async function handleListRooms(env) {
try {
const rooms = [];
const list = await env.ROOM_METADATA.list();
for (const key of list.keys) {
const roomData = JSON.parse(await env.ROOM_METADATA.get(key.name));
rooms.push(roomData);
}
return new Response(JSON.stringify({ rooms }), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
function getWebSocketTestPage() {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cloudflare Workers WebSocket Test</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
.container { background: #f5f5f5; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input, select { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
button { background: #007cba; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; margin-right: 10px; }
button:hover { background: #005a87; }
button:disabled { background: #ccc; cursor: not-allowed; }
.messages { height: 400px; overflow-y: auto; border: 1px solid #ddd; padding: 10px; background: white; }
.message { margin-bottom: 10px; padding: 8px; border-radius: 4px; }
.message.own { background: #e3f2fd; text-align: right; }
.message.other { background: #f3e5f5; }
.message.system { background: #fff3e0; font-style: italic; }
.message.private { background: #e8f5e8; border-left: 4px solid #4caf50; }
.typing-indicator { color: #666; font-style: italic; }
.users-list { background: white; padding: 10px; border: 1px solid #ddd; border-radius: 4px; }
.status { padding: 10px; margin-bottom: 20px; border-radius: 4px; }
.status.connected { background: #d4edda; color: #155724; }
.status.disconnected { background: #f8d7da; color: #721c24; }
.status.connecting { background: #fff3cd; color: #856404; }
</style>
</head>
<body>
<h1>🌐 Cloudflare Workers WebSocket Test</h1>
<div id="connectionStatus" class="status disconnected">
Disconnected
</div>
<div class="container">
<h2>Connect to Room</h2>
<div class="form-group">
<label for="roomName">Room Name:</label>
<input type="text" id="roomName" placeholder="my-room" required>
</div>
<button id="connectBtn" onclick="connect()">Connect</button>
<button id="disconnectBtn" onclick="disconnect()" disabled>Disconnect</button>
<button onclick="createRoom()">Create New Room</button>
</div>
<div class="container">
<h2>Chat</h2>
<div class="form-group">
<label for="nickname">Nickname:</label>
<input type="text" id="nickname" placeholder="Enter nickname">
<button onclick="setNickname()">Set Nickname</button>
</div>
<div class="form-group">
<label for="message">Message:</label>
<input type="text" id="message" placeholder="Type a message..." onkeypress="if(event.key==='Enter') sendMessage()">
<button onclick="sendMessage()">Send</button>
</div>
<div class="messages" id="messages"></div>
<div id="typingIndicator" class="typing-indicator"></div>
</div>
<div class="container">
<h2>Users</h2>
<div class="users-list" id="usersList">No users connected</div>
</div>
<script>
let ws = null;
let currentRoom = null;
let mySessionId = null;
function updateStatus(status, message) {
const statusEl = document.getElementById('connectionStatus');
statusEl.className = `status ${status}`;
statusEl.textContent = message;
}
function addMessage(content, type = 'system') {
const messagesEl = document.getElementById('messages');
const messageEl = document.createElement('div');
messageEl.className = `message ${type}`;
messageEl.innerHTML = content;
messagesEl.appendChild(messageEl);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function connect() {
const roomName = document.getElementById('roomName').value.trim();
if (!roomName) {
alert('Please enter a room name');
return;
}
const wsUrl = new URL(window.location.href);
wsUrl.protocol = wsUrl.protocol.replace('http', 'ws');
wsUrl.pathname = `/room/${roomName}/socket`;
ws = new WebSocket(wsUrl.toString());
updateStatus('connecting', 'Connecting...');
currentRoom = roomName;
ws.onopen = () => {
updateStatus('connected', `Connected to room: ${roomName}`);
document.getElementById('connectBtn').disabled = true;
document.getElementById('disconnectBtn').disabled = false;
addMessage(`Connected to room ${roomName}`, 'system');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
};
ws.onclose = () => {
updateStatus('disconnected', 'Disconnected');
document.getElementById('connectBtn').disabled = false;
document.getElementById('disconnectBtn').disabled = true;
ws = null;
currentRoom = null;
mySessionId = null;
addMessage('Disconnected from server', 'system');
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
updateStatus('disconnected', 'Connection error');
addMessage('Connection error occurred', 'system');
};
}
function disconnect() {
if (ws) {
ws.close();
}
}
function handleWebSocketMessage(data) {
switch (data.type) {
case 'welcome':
mySessionId = data.data.sessionId;
addMessage(`Welcome! Your session ID: ${data.data.sessionId}`, 'system');
break;
case 'chatMessage':
const isOwn = data.data.sessionId === mySessionId;
const messageType = isOwn ? 'own' : 'other';
addMessage(`
<strong>${data.data.nickname}:</strong> ${data.data.message}
<div style="font-size: 0.8em; color: #666;">
${new Date(data.data.timestamp).toLocaleTimeString()}
</div>
`, messageType);
break;
case 'userJoined':
addMessage(`${data.data.user.nickname} joined the room`, 'system');
break;
case 'userLeft':
addMessage(`${data.data.user.nickname} left the room`, 'system');
break;
case 'userTyping':
if (data.data.isTyping) {
document.getElementById('typingIndicator').textContent =
`${data.data.nickname} is typing...`;
} else {
document.getElementById('typingIndicator').textContent = '';
}
break;
case 'nicknameChanged':
addMessage(`User changed nickname`, 'system');
break;
case 'privateMessage':
const isPrivateOwn = data.data.fromSessionId === mySessionId;
addMessage(`
<div style="color: #2e7d32;">
<strong>🔒 Private from ${data.data.fromNickname}:</strong> ${data.data.message}
</div>
`, 'private');
break;
case 'privateMessageSent':
addMessage(`
<div style="color: #2e7d32; text-align: right;">
<strong>🔒 Private to ${data.data.toSessionId}:</strong> ${data.data.message}
</div>
`, 'private');
break;
case 'error':
addMessage(`Error: ${data.data.message}`, 'system');
break;
default:
console.log('Unknown message type:', data);
}
}
function sendMessage() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
alert('Not connected');
return;
}
const messageInput = document.getElementById('message');
const message = messageInput.value.trim();
if (message) {
ws.send(JSON.stringify({
type: 'chatMessage',
data: { message }
}));
messageInput.value = '';
}
}
function setNickname() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
alert('Not connected');
return;
}
const nicknameInput = document.getElementById('nickname');
const nickname = nicknameInput.value.trim();
if (nickname) {
ws.send(JSON.stringify({
type: 'setNickname',
data: { nickname }
}));
}
}
function createRoom() {
const roomName = prompt('Enter room name:');
if (!roomName) return;
fetch('/create-room', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roomName, maxClients: 50 })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`Room created: ${data.roomName}`);
document.getElementById('roomName').value = data.roomName;
connect();
} else {
alert('Error: ' + data.error);
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
// Handle typing indicator
let typingTimer;
document.getElementById('message').addEventListener('input', () => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'typing',
data: { isTyping: true }
}));
clearTimeout(typingTimer);
typingTimer = setTimeout(() => {
ws.send(JSON.stringify({
type: 'typing',
data: { isTyping: false }
}));
}, 1000);
}
});
</script>
</body>
</html>
`;
}
// wrangler.toml
/*
name = "websocket-server"
main = "src/index.js"
compatibility_date = "2023-10-30"
# Durable Objects
[[durable_objects.bindings]]
name = "CHAT_ROOMS"
class_name = "ChatRoom"
# KV for room metadata
[[kv_namespaces]]
binding = "ROOM_METADATA"
id = "your-kv-namespace-id"
preview_id = "your-preview-kv-namespace-id"
*/
💻 Worker CMS Edge-First typescript
🔴 complex
⭐⭐⭐⭐⭐
CMS headless con edge caching, borradores y entrega de contenido en tiempo real
⏱️ 50 min
🏷️ cloudflare, workers, cms, content-management, durable-objects
Prerequisites:
Advanced Cloudflare Workers knowledge, TypeScript, CMS concepts, Durable Objects
// Cloudflare Workers Edge-First CMS
// src/index.ts - Content management with edge caching and real-time updates
import { nanoid } from 'nanoid';
interface Content {
id: string;
type: 'page' | 'post' | 'component' | 'asset';
slug: string;
title: string;
content: any;
status: 'draft' | 'published' | 'archived';
authorId: string;
authorName: string;
createdAt: number;
updatedAt: number;
publishedAt?: number;
tags: string[];
metadata: Record<string, any>;
seo?: {
title?: string;
description?: string;
keywords?: string[];
};
}
interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'editor' | 'author';
permissions: string[];
createdAt: number;
lastLogin?: number;
}
// Durable Object for content storage
export class ContentStore {
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
}
private async initializeContent() {
const hasContent = await this.state.storage.get('initialized');
if (!hasContent) {
await this.state.storage.put('initialized', true);
await this.state.storage.put('content:pages', []);
await this.state.storage.put('content:posts', []);
await this.state.storage.put('content:components', []);
await this.state.storage.put('content:assets', []);
await this.state.storage.put('users', []);
console.log('Content store initialized');
}
}
async fetch(request: Request): Promise<Response> {
await this.initializeContent();
const url = new URL(request.url);
const pathname = url.pathname;
// Handle content API
if (pathname.startsWith('/api/content/')) {
return this.handleContentAPI(request, pathname.replace('/api/content/', ''));
}
// Handle user authentication
if (pathname.startsWith('/api/auth/')) {
return this.handleAuthAPI(request, pathname.replace('/api/auth/', ''));
}
// Handle preview requests
if (pathname.startsWith('/preview/')) {
return this.handlePreview(request, pathname.replace('/preview/', ''));
}
// Handle public content delivery
if (pathname.startsWith('/content/') || pathname === '/') {
return this.handlePublicContent(request);
}
return new Response('Not Found', { status: 404 });
}
private async handleContentAPI(request: Request, endpoint: string): Promise<Response> {
const method = request.method;
const url = new URL(request.url);
// Verify authentication for write operations
if (['POST', 'PUT', 'DELETE'].includes(method)) {
const authResult = await this.authenticateRequest(request);
if (!authResult.success) {
return new Response(JSON.stringify({ error: authResult.error }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
}
try {
switch (true) {
case endpoint === '' && method === 'GET':
return this.getContentList(url);
case endpoint.endsWith('/new') && method === 'POST':
return this.createContent(request, endpoint.replace('/new', ''));
case endpoint.match(/^[\w-]+$/) && method === 'GET':
return this.getContent(endpoint);
case endpoint.match(/^[\w-]+$/) && method === 'PUT':
return this.updateContent(endpoint, request);
case endpoint.match(/^[\w-]+$/) && method === 'DELETE':
return this.deleteContent(endpoint);
case endpoint === 'search' && method === 'GET':
return this.searchContent(url);
case endpoint === 'tags' && method === 'GET':
return this.getTags();
default:
return new Response('Endpoint not found', { status: 404 });
}
} catch (error) {
console.error('Content API error:', error);
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
private async getContentList(url: URL): Promise<Response> {
const type = url.searchParams.get('type') || 'all';
const status = url.searchParams.get('status') || 'published';
const limit = parseInt(url.searchParams.get('limit') || '20');
const offset = parseInt(url.searchParams.get('offset') || '0');
const sortBy = url.searchParams.get('sortBy') || 'updatedAt';
const sortOrder = url.searchParams.get('sortOrder') || 'desc';
const tags = url.searchParams.getAll('tags');
let content: Content[] = [];
// Get content based on type
if (type === 'all') {
const pages = await this.state.storage.get<Content[]>('content:pages') || [];
const posts = await this.state.storage.get<Content[]>('content:posts') || [];
const components = await this.state.storage.get<Content[]>('content:components') || [];
content = [...pages, ...posts, ...components];
} else {
content = await this.state.storage.get<Content[]>(`content:${type}s`) || [];
}
// Filter by status
content = content.filter(item => status === 'all' || item.status === status);
// Filter by tags
if (tags.length > 0) {
content = content.filter(item =>
tags.some(tag => item.tags.includes(tag))
);
}
// Sort
content.sort((a, b) => {
const aValue = a[sortBy as keyof Content];
const bValue = b[sortBy as keyof Content];
if (sortOrder === 'desc') {
return bValue - aValue;
} else {
return aValue - bValue;
}
});
// Paginate
const paginatedContent = content.slice(offset, offset + limit);
return new Response(JSON.stringify({
content: paginatedContent,
pagination: {
total: content.length,
limit,
offset,
hasMore: offset + limit < content.length
}
}), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300' // 5 minutes
}
});
}
private async getContent(slug: string): Promise<Response> {
const allTypes = ['pages', 'posts', 'components', 'assets'];
let content: Content | null = null;
for (const type of allTypes) {
const items = await this.state.storage.get<Content[]>(`content:${type}`) || [];
content = items.find(item => item.slug === slug) || null;
if (content) break;
}
if (!content) {
return new Response(JSON.stringify({ error: 'Content not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
// Only return published content for public requests
if (content.status !== 'published') {
const authResult = await this.authenticateRequest(new Request(''));
if (!authResult.success) {
return new Response(JSON.stringify({ error: 'Content not available' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
}
return new Response(JSON.stringify(content), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': content.status === 'published'
? 'public, max-age=3600' // 1 hour for published
: 'no-cache' // No caching for drafts
}
});
}
private async createContent(request: Request, type: string): Promise<Response> {
const data = await request.json() as Partial<Content>;
const user = await this.getCurrentUser(request);
if (!user) {
return new Response(JSON.stringify({ error: 'User not found' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
// Validate required fields
if (!data.title || !data.slug) {
return new Response(JSON.stringify({
error: 'Title and slug are required'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Check if slug already exists
const existingContent = await this.findBySlug(data.slug);
if (existingContent) {
return new Response(JSON.stringify({
error: 'Slug already exists'
}), {
status: 409,
headers: { 'Content-Type': 'application/json' }
});
}
const newContent: Content = {
id: nanoid(),
type: type as Content['type'],
slug: data.slug,
title: data.title,
content: data.content || {},
status: data.status || 'draft',
authorId: user.id,
authorName: user.name,
createdAt: Date.now(),
updatedAt: Date.now(),
publishedAt: data.status === 'published' ? Date.now() : undefined,
tags: data.tags || [],
metadata: data.metadata || {},
seo: data.seo
};
// Save to appropriate content array
const storageKey = `content:${type}s`;
const contentArray = await this.state.storage.get<Content[]>(storageKey) || [];
contentArray.push(newContent);
await this.state.storage.put(storageKey, contentArray);
// Invalidate cache for this content type
await this.invalidateCache(type);
return new Response(JSON.stringify(newContent), {
status: 201,
headers: { 'Content-Type': 'application/json' }
});
}
private async updateContent(slug: string, request: Request): Promise<Response> {
const user = await this.getCurrentUser(request);
if (!user) {
return new Response(JSON.stringify({ error: 'User not found' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const data = await request.json() as Partial<Content>;
const existingContent = await this.findBySlug(slug);
if (!existingContent) {
return new Response(JSON.stringify({ error: 'Content not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
// Check permissions
if (!this.canEditContent(user, existingContent)) {
return new Response(JSON.stringify({
error: 'Insufficient permissions'
}), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
// Update content
const updatedContent: Content = {
...existingContent,
...data,
id: existingContent.id, // Don't allow ID changes
slug: data.slug || existingContent.slug,
updatedAt: Date.now(),
authorId: existingContent.authorId, // Don't allow author changes
authorName: existingContent.authorName
};
// Handle status changes
if (data.status && data.status !== existingContent.status) {
if (data.status === 'published' && existingContent.status !== 'published') {
updatedContent.publishedAt = Date.now();
// Notify subscribers about new content
await this.notifySubscribers('content_published', updatedContent);
}
}
// Update in storage
const storageKey = `content:${existingContent.type}s`;
const contentArray = await this.state.storage.get<Content[]>(storageKey) || [];
const index = contentArray.findIndex(item => item.slug === slug);
if (index !== -1) {
contentArray[index] = updatedContent;
await this.state.storage.put(storageKey, contentArray);
}
// Invalidate cache
await this.invalidateCache(existingContent.type);
return new Response(JSON.stringify(updatedContent), {
headers: { 'Content-Type': 'application/json' }
});
}
private async deleteContent(slug: string): Promise<Response> {
const user = await this.getCurrentUser(new Request(''));
if (!user || user.role !== 'admin') {
return new Response(JSON.stringify({
error: 'Only admins can delete content'
}), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
const existingContent = await this.findBySlug(slug);
if (!existingContent) {
return new Response(JSON.stringify({ error: 'Content not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
// Remove from storage
const storageKey = `content:${existingContent.type}s`;
const contentArray = await this.state.storage.get<Content[]>(storageKey) || [];
const filteredArray = contentArray.filter(item => item.slug !== slug);
await this.state.storage.put(storageKey, filteredArray);
// Invalidate cache
await this.invalidateCache(existingContent.type);
return new Response(JSON.stringify({
success: true,
message: 'Content deleted successfully'
}), {
headers: { 'Content-Type': 'application/json' }
});
}
private async searchContent(url: URL): Promise<Response> {
const query = url.searchParams.get('q')?.toLowerCase() || '';
const type = url.searchParams.get('type') || 'all';
const limit = parseInt(url.searchParams.get('limit') || '10');
if (!query) {
return new Response(JSON.stringify({ error: 'Search query is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
let allContent: Content[] = [];
// Get all content based on type
if (type === 'all') {
const pages = await this.state.storage.get<Content[]>('content:pages') || [];
const posts = await this.state.storage.get<Content[]>('content:posts') || [];
const components = await this.state.storage.get<Content[]>('content:components') || [];
allContent = [...pages, ...posts, ...components];
} else {
allContent = await this.state.storage.get<Content[]>(`content:${type}s`) || [];
}
// Search in title and content
const searchResults = allContent.filter(item => {
if (item.status !== 'published') return false;
const titleMatch = item.title.toLowerCase().includes(query);
const contentMatch = JSON.stringify(item.content).toLowerCase().includes(query);
const tagsMatch = item.tags.some(tag => tag.toLowerCase().includes(query));
return titleMatch || contentMatch || tagsMatch;
}).slice(0, limit);
return new Response(JSON.stringify({
query,
results: searchResults,
total: searchResults.length
}), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=600' // 10 minutes
}
});
}
private async getTags(): Promise<Response> {
const allTypes = ['pages', 'posts', 'components'];
const allTags = new Set<string>();
for (const type of allTypes) {
const items = await this.state.storage.get<Content[]>(`content:${type}s`) || [];
items.forEach(item => {
item.tags.forEach(tag => allTags.add(tag));
});
}
const sortedTags = Array.from(allTags).sort();
return new Response(JSON.stringify(sortedTags), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600' // 1 hour
}
});
}
private async handlePublicContent(request: Request): Promise<Response> {
const url = new URL(request.url);
const pathname = url.pathname;
// Serve specific content by slug
if (pathname !== '/' && pathname.startsWith('/content/')) {
const slug = pathname.replace('/content/', '');
const content = await this.findBySlug(slug);
if (!content || content.status !== 'published') {
return new Response('Content not found', { status: 404 });
}
// Render content based on type
switch (content.type) {
case 'page':
return this.renderPage(content, url);
case 'post':
return this.renderPost(content, url);
case 'component':
return this.renderComponent(content, url);
default:
return new Response(JSON.stringify(content), {
headers: { 'Content-Type': 'application/json' }
});
}
}
// Serve homepage
if (pathname === '/') {
return this.renderHomepage(url);
}
return new Response('Not found', { status: 404 });
}
private async renderPage(page: Content, url: URL): Promise<Response> {
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${page.seo?.title || page.title}</title>
<meta name="description" content="${page.seo?.description || page.content.excerpt || ''}">
<meta name="keywords" content="${(page.seo?.keywords || []).join(', ')}">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
<a href="/content/blog">Blog</a>
<a href="/admin" style="display: none;">Admin</a>
</nav>
</header>
<main>
<article class="page">
<h1>${page.title}</h1>
<div class="content">
${this.renderContent(page.content)}
</div>
${page.tags.length > 0 ? `
<div class="tags">
${page.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
</div>
` : ''}
</article>
</main>
<footer>
<p>© ${new Date().getFullYear()} Edge CMS. Powered by Cloudflare Workers.</p>
</footer>
<script src="/static/cms.js"></script>
</body>
</html>
`;
return new Response(html, {
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'public, max-age=3600'
}
});
}
private renderContent(content: any): string {
if (typeof content === 'string') {
return content;
}
// Handle structured content
if (content.blocks && Array.isArray(content.blocks)) {
return content.blocks.map((block: any) => {
switch (block.type) {
case 'heading':
return `<h${block.level || 2}>${block.text}</h${block.level || 2}>`;
case 'paragraph':
return `<p>${block.text}</p>`;
case 'image':
return `<img src="${block.src}" alt="${block.alt || ''}" />`;
case 'code':
return `<pre><code>${block.code}</code></pre>`;
case 'list':
return `<ul>${block.items.map((item: string) => `<li>${item}</li>`).join('')}</ul>`;
default:
return `<div>${JSON.stringify(block)}</div>`;
}
}).join('\n');
}
return JSON.stringify(content);
}
// Helper methods
private async findBySlug(slug: string): Promise<Content | null> {
const allTypes = ['pages', 'posts', 'components', 'assets'];
for (const type of allTypes) {
const items = await this.state.storage.get<Content[]>(`content:${type}s`) || [];
const found = items.find(item => item.slug === slug);
if (found) return found;
}
return null;
}
private async authenticateRequest(request: Request): Promise<{ success: boolean; error?: string; user?: User }> {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return { success: false, error: 'No authorization header' };
}
const token = authHeader.replace('Bearer ', '');
// Simple token validation (in production, use proper JWT validation)
const users = await this.state.storage.get<User[]>('users') || [];
const user = users.find(u => u.email === token); // Simplified - using email as token
if (!user) {
return { success: false, error: 'Invalid token' };
}
return { success: true, user };
} catch (error) {
return { success: false, error: 'Authentication error' };
}
}
private async getCurrentUser(request: Request): Promise<User | null> {
const auth = await this.authenticateRequest(request);
return auth.user || null;
}
private canEditContent(user: User, content: Content): boolean {
if (user.role === 'admin') return true;
if (user.role === 'editor') return true;
if (user.role === 'author' && content.authorId === user.id) return true;
return false;
}
private async invalidateCache(type: string): Promise<void> {
// In a real implementation, you would invalidate Cloudflare cache
console.log(`Cache invalidated for type: ${type}`);
}
private async notifySubscribers(event: string, content: Content): Promise<void> {
// In a real implementation, send webhook notifications
console.log(`Notification sent for ${event}: ${content.title}`);
}
private async handleAuthAPI(request: Request, endpoint: string): Promise<Response> {
// Authentication API implementation
// This would include login, logout, user management, etc.
return new Response(JSON.stringify({ error: 'Auth API not implemented' }), {
status: 501,
headers: { 'Content-Type': 'application/json' }
});
}
private async handlePreview(request: Request, slug: string): Promise<Response> {
const content = await this.findBySlug(slug);
if (!content) {
return new Response('Content not found', { status: 404 });
}
// Show preview even for drafts (in production, check permissions)
return this.renderPage(content, new URL(request.url));
}
private async renderHomepage(url: URL): Promise<Response> {
// Get latest posts
const posts = await this.state.storage.get<Content[]>('content:posts') || [];
const publishedPosts = posts
.filter(post => post.status === 'published')
.sort((a, b) => (b.publishedAt || 0) - (a.publishedAt || 0))
.slice(0, 5);
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edge CMS - Home</title>
<meta name="description" content="Edge-first content management system powered by Cloudflare Workers">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
<a href="/content/blog">Blog</a>
<a href="/admin">Admin</a>
</nav>
</header>
<main>
<section class="hero">
<h1>Welcome to Edge CMS</h1>
<p>Lightning-fast content delivery powered by Cloudflare Workers</p>
</section>
<section class="latest-posts">
<h2>Latest Posts</h2>
${publishedPosts.map(post => `
<article class="post-preview">
<h3><a href="/content/${post.slug}">${post.title}</a></h3>
<p>${post.content.excerpt || ''}</p>
<div class="meta">
By ${post.authorName} • ${new Date(post.publishedAt || post.createdAt).toLocaleDateString()}
</div>
</article>
`).join('')}
</section>
</main>
<footer>
<p>© ${new Date().getFullYear()} Edge CMS. Powered by Cloudflare Workers.</p>
</footer>
</body>
</html>
`;
return new Response(html, {
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'public, max-age=300'
}
});
}
private async renderPost(post: Content, url: URL): Promise<Response> {
// Similar to renderPage but optimized for blog posts
return this.renderPage(post, url);
}
private async renderComponent(component: Content, url: URL): Promise<Response> {
// Render reusable components
return new Response(this.renderContent(component.content), {
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'public, max-age=3600'
}
});
}
}
// Main worker
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const pathname = url.pathname;
// Route to content store Durable Object
if (pathname.startsWith('/api/') ||
pathname.startsWith('/content/') ||
pathname.startsWith('/preview/') ||
pathname === '/') {
const contentStore = env.CONTENT_STORE.get(env.CONTENT_STORE.idFromName('default'));
return contentStore.fetch(request);
}
// Serve static assets
if (pathname.startsWith('/static/')) {
// In production, you would serve from R2 storage
return new Response('Static file not found', { status: 404 });
}
return new Response('Not Found', { status: 404 });
}
};
// Environment types
interface Env {
CONTENT_STORE: DurableObjectNamespace;
AUTH_SECRET: string;
// Add other environment variables as needed
}
// wrangler.toml
/*
name = "edge-cms"
main = "src/index.ts"
compatibility_date = "2023-10-30"
# Durable Objects
[[durable_objects.bindings]]
name = "CONTENT_STORE"
class_name = "ContentStore"
# Environment variables
[vars]
ENVIRONMENT = "production"
# KV for caching (optional)
[[kv_namespaces]]
binding = "CMS_CACHE"
id = "your-kv-namespace-id"
preview_id = "your-preview-kv-namespace-id"
# R2 for asset storage (optional)
[[r2_buckets]]
binding = "ASSETS"
bucket_name = "your-asset-bucket"
*/