cloudflare-browser-rendering-mcp
by amotivv
Verified
import puppeteer from '@cloudflare/puppeteer';
/**
* Cloudflare Worker with Browser Rendering binding
*
* This worker demonstrates how to use the Browser Rendering binding
* with the @cloudflare/puppeteer package.
*/
// Constants for the KV storage
const SCREENSHOT_EXPIRATION = 60 * 60; // 1 hour in seconds
/**
* Generate a unique ID for screenshot storage
*/
function generateUniqueId() {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
// Define the allowed origins for CORS
const ALLOWED_ORIGINS = [
'https://example.com',
'http://localhost:3000',
];
/**
* Handle CORS preflight requests
*/
function handleOptions(request) {
const origin = request.headers.get('Origin');
const isAllowedOrigin = ALLOWED_ORIGINS.includes(origin);
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': isAllowedOrigin ? origin : ALLOWED_ORIGINS[0],
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
},
});
}
/**
* Add CORS headers to a response
*/
function addCorsHeaders(response, request) {
const origin = request.headers.get('Origin');
const isAllowedOrigin = ALLOWED_ORIGINS.includes(origin);
const headers = new Headers(response.headers);
headers.set('Access-Control-Allow-Origin', isAllowedOrigin ? origin : ALLOWED_ORIGINS[0]);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
/**
* Handle the /content endpoint
*/
async function handleContent(request, env) {
try {
// Parse the request body
const body = await request.json();
if (!body.url) {
return new Response(JSON.stringify({ error: 'URL is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Launch a browser using the binding
const browser = await puppeteer.launch(env.browser);
try {
// Create a new page
const page = await browser.newPage();
// Set viewport size
await page.setViewport({
width: 1280,
height: 800,
});
// Set request rejection patterns if provided
if (body.rejectResourceTypes && Array.isArray(body.rejectResourceTypes)) {
await page.setRequestInterception(true);
page.on('request', (req) => {
if (body.rejectResourceTypes.includes(req.resourceType())) {
req.abort();
} else {
req.continue();
}
});
}
// Navigate to the URL
await page.goto(body.url, {
waitUntil: body.waitUntil || 'networkidle0',
timeout: body.timeout || 30000,
});
// Get the page content
const content = await page.content();
// Return the content
return new Response(JSON.stringify({ content }), {
headers: { 'Content-Type': 'application/json' },
});
} finally {
// Always close the browser to avoid resource leaks
await browser.close();
}
} catch (error) {
return new Response(JSON.stringify({ error: error.message, stack: error.stack }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
/**
* Handle the /image/{id} endpoint to serve cached screenshots
*/
async function handleImage(request, env) {
try {
const url = new URL(request.url);
const pathParts = url.pathname.split('/');
const id = pathParts[pathParts.length - 1];
console.log(`[API] Requested image with ID: ${id}`);
// Get the image metadata and data from KV
const metadata = await env.SCREENSHOTS.get(`${id}:meta`, { type: 'json' });
if (!metadata) {
console.error(`[Error] Image metadata not found in KV: ${id}`);
return new Response('Image not found', {
status: 404,
headers: { 'Content-Type': 'text/plain' }
});
}
// Get the actual image data
const base64Data = await env.SCREENSHOTS.get(`${id}:data`, { type: 'text' });
if (!base64Data) {
console.error(`[Error] Image data not found in KV: ${id}`);
return new Response('Image data not found', {
status: 404,
headers: { 'Content-Type': 'text/plain' }
});
}
// Convert base64 to binary using Cloudflare Workers API
const binaryData = atob(base64Data);
const bytes = new Uint8Array(binaryData.length);
for (let i = 0; i < binaryData.length; i++) {
bytes[i] = binaryData.charCodeAt(i);
}
console.log(`[API] Serving image ${id} with content type: ${metadata.contentType}`);
return new Response(bytes, {
headers: {
'Content-Type': metadata.contentType,
'Cache-Control': 'public, max-age=3600' // Allow browser caching for 1 hour
}
});
} catch (error) {
console.error('[Error] Error serving image:', error);
return new Response(`Error serving image: ${error.message}`, {
status: 500,
headers: { 'Content-Type': 'text/plain' }
});
}
}
/**
* Handle the /screenshot endpoint
*/
async function handleScreenshot(request, env) {
let browser = null;
try {
console.log('[Setup] Starting screenshot process');
// Parse the request body
const body = await request.json();
if (!body.url) {
return new Response(JSON.stringify({ error: 'URL is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Validate URL
try {
new URL(body.url);
} catch (e) {
return new Response(JSON.stringify({ error: `Invalid URL: ${body.url}` }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
console.log(`[API] Processing screenshot request for URL: ${body.url}`);
// Check if we have a valid browser binding
if (!env.browser) {
return new Response(JSON.stringify({ error: 'Browser binding is not available' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
// Check if KV is available
if (!env.SCREENSHOTS) {
return new Response(JSON.stringify({ error: 'SCREENSHOTS KV binding is not available' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
// Launch a browser using the binding with timeout
const browserLaunchTimeout = setTimeout(() => {
if (!browser) {
throw new Error('Browser launch timed out');
}
}, 15000); // 15-second timeout for browser launch
browser = await puppeteer.launch(env.browser);
clearTimeout(browserLaunchTimeout);
console.log('[Setup] Browser launched successfully');
try {
// Create a new page
const page = await browser.newPage();
// Set smaller viewport size to reduce memory usage
const width = Math.min(body.width || 1280, 1600);
const height = Math.min(body.height || 800, 1200);
await page.setViewport({
width,
height,
});
// Set navigation timeout
const timeout = Math.min(body.timeout || 30000, 60000); // Cap at 60 seconds
// Abort requests to unnecessary resources to improve performance
await page.setRequestInterception(true);
page.on('request', (req) => {
const resourceType = req.resourceType();
if (['image', 'font', 'media'].includes(resourceType) && !body.includeResources) {
req.abort();
} else {
req.continue();
}
});
// Navigate to the URL with timeout
console.log(`[API] Navigating to ${body.url}`);
await page.goto(body.url, {
waitUntil: body.waitUntil || 'networkidle2', // Using networkidle2 instead of networkidle0 for better reliability
timeout,
});
console.log('[API] Navigation completed, taking screenshot');
// Cap fullPage option to reduce memory usage
const fullPage = body.fullPage === true && !body.forceFullPage ? false : body.fullPage || false;
// Take a screenshot with reduced quality to save memory
const screenshot = await page.screenshot({
fullPage,
type: 'jpeg', // Use JPEG instead of PNG for smaller file size
quality: 80, // Reduce quality to save memory
encoding: 'base64',
});
console.log('[API] Screenshot taken successfully');
// Generate a unique ID for this screenshot
const id = generateUniqueId();
const contentType = 'image/jpeg';
// Create metadata for the screenshot
const metadata = {
contentType,
width,
height,
fullPage,
format: 'jpeg',
timestamp: Date.now(),
originalUrl: body.url
};
// Store both metadata and image data in KV with expiration
const expirationTtl = SCREENSHOT_EXPIRATION; // 1 hour in seconds
// Store metadata and image data in KV
await Promise.all([
env.SCREENSHOTS.put(`${id}:meta`, JSON.stringify(metadata), { expirationTtl }),
env.SCREENSHOTS.put(`${id}:data`, screenshot, { expirationTtl })
]);
console.log(`[API] Screenshot saved to KV with ID: ${id}`);
// Generate URL (using the request URL to get the origin)
const origin = new URL(request.url).origin;
const imageUrl = `${origin}/image/${id}`;
console.log(`[API] Screenshot processed successfully, assigned ID: ${id}`);
// Return only the URL (no base64 data) and include metadata
return new Response(JSON.stringify({
url: imageUrl,
width,
height,
format: 'jpeg',
fullPage,
expiresIn: `${expirationTtl} seconds`,
id
}), {
headers: { 'Content-Type': 'application/json' },
});
} finally {
// Always close the browser to avoid resource leaks
if (browser) {
console.log('[Setup] Closing browser');
await browser.close();
browser = null;
}
}
} catch (error) {
console.error('[Error] Error in screenshot process:', error);
// Ensure browser is closed even on error
if (browser) {
try {
await browser.close();
} catch (closeError) {
console.error('[Error] Error closing browser:', closeError);
}
}
return new Response(JSON.stringify({
error: error.message,
details: error.stack,
type: 'screenshot_error'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
// KV values expire automatically based on the expirationTtl so no explicit cleanup is needed
/**
* Main worker handler
*/
export default {
async fetch(request, env, ctx) {
// Handle CORS preflight requests
if (request.method === 'OPTIONS') {
return handleOptions(request);
}
// Get the URL pathname
const url = new URL(request.url);
const path = url.pathname.toLowerCase();
// Route the request to the appropriate handler
let response;
if (path.endsWith('/content')) {
response = await handleContent(request, env);
} else if (path.endsWith('/screenshot')) {
response = await handleScreenshot(request, env);
} else if (path.match(/\/image\/[a-z0-9]+$/)) {
response = await handleImage(request, env);
} else {
response = new Response(JSON.stringify({
error: 'Not found',
endpoints: ['/content', '/screenshot', '/image/{id}']
}), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
// No cleanup needed as KV handles expiration automatically
// Add CORS headers to the response
return addCorsHeaders(response, request);
},
// Scheduled handler
async scheduled(event, env, ctx) {
// No cleanup needed as KV handles expiration automatically
}
};