[![GitHub](https://camo.githubusercontent.com/c003beec627d2194209ee3ff45ac1497299ead75de4bed3248a7d70b78cdacf5/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f627973616765732f6c656e73)](https://camo.githubusercontent.com/c003beec627d2194209ee3ff45ac1497299ead75de4bed3248a7d70b78cdacf5/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f627973616765732f6c656e73) [![Contributor Covenant](https://camo.githubusercontent.com/4ae39ae593b602cf0ae07972b61c73728b77ec8e2cf40f579a2441948208036b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f436f6e7472696275746f72253230436f76656e616e742d322e312d3462616161612e737667)](https://www.contributor-covenant.org/version/2/1/code_of_conduct/)

> A high-performance image proxy and web services toolkit built with modern TypeScript. Lens provides a comprehensive suite of web utilities including image processing, screenshot capture, font serving, and more.

## 🚀 Features

[](#-features)

### Core Services

[](#core-services)

*   **🖼️ Image Proxy**: IPX-powered image processing with resize, format conversion, optimization
*   **📸 Screenshot Capture**: Fast website screenshot service with resource optimization
*   **📄 Reader**: Article content extraction (Markdown/HTML) with Readability
*   **🅰️ Font Service**: Google Fonts compatible API with multiple providers
*   **🎨 Open Graph Images**: Dynamic OG image generation
*   **🎯 Favicon Extraction**: Smart favicon extraction from websites
*   **👤 Gravatar Proxy**: Cached Gravatar avatar proxy with email or hash input
*   **🤖 MCP Server**: Model Context Protocol server exposing all services as tools via JSON-RPC 2.0

### Performance & Scalability

[](#performance--scalability)

*   **⚡ Redis Caching**: Redis-powered caching with 24-hour intelligent caching
*   **🧭 On-Demand Browser**: Moli headless browser (Rust) driven over CDP — ~1/10 the memory of Chromium
*   **🛡️ Rate Limiting**: Rate limiting for expensive operations
*   **🔒 SSRF Protection**: URL validation with DNS resolution checks, plus browser-level private-network blocking
*   **🔄 Graceful Degradation**: Automatic fallbacks for missing services

## 📦 Quick Start

[](#-quick-start)

### Prerequisites

[](#prerequisites)

*   Node.js 22+
*   pnpm 10+ (recommended) or npm
*   [Moli](https://github.com/lexmount/moli) headless browser (screenshot/reader connect to it over CDP)

### Installation

[](#installation)

# Clone the repository
git clone https://github.com/bysages/lens.git
cd lens

# Install dependencies
pnpm install

# Copy environment configuration
cp .env.example .env

### Development

[](#development)

# Start the Moli browser server (default CDP endpoint: http://127.0.0.1:9222)
moli serve --layout --resource

# In another terminal, start development server
pnpm dev

# Build for production
pnpm build

# Preview production build
pnpm preview

The server will start on `http://localhost:3000` by default.

## 🛠️ Configuration

[](#️-configuration)

### Configuration

[](#configuration)

All configurations are optional and will gracefully degrade:

# Image proxy security
ALLOWED\_DOMAINS\=example.com,cdn.example.com

# Set only when requests arrive through a trusted reverse proxy
TRUST\_PROXY\=1

# Requests per IP within RATE\_WINDOW seconds; Redis makes these cluster-wide
BROWSER\_RATE\_LIMIT\=20
FAVICON\_RATE\_LIMIT\=30
IMAGE\_RATE\_LIMIT\=120
GRAVATAR\_RATE\_LIMIT\=120
RATE\_WINDOW\=60

# Caching (significantly improves performance)
REDIS\_URL\=redis://localhost:6379

# Browser: Moli CDP endpoint (defaults to http://127.0.0.1:9222)
BROWSER\_CDP\_ENDPOINT\=http://127.0.0.1:9222

# Browser: optional user-agent overrides (default: the engine's own UA)
#BROWSER\_USER\_AGENT=...
#BROWSER\_USER\_AGENT\_MOBILE=...

# Browser: cluster-wide maximum with Redis (process-local fallback otherwise)
BROWSER\_MAX\_CONCURRENCY\=4

# Maximum wait for a global browser slot (ms)
BROWSER\_QUEUE\_TIMEOUT\=15000

# Reject full-page captures above this CSS pixel count
SCREENSHOT\_MAX\_PIXELS\=24000000

# Cluster: worker count (Docker defaults to 2; raw Nitro defaults to CPU count)
NITRO\_CLUSTER\_WORKERS\=2

See `.env.example` for all available options.

## 📡 API Reference

[](#-api-reference)

All cached responses include `X-Cache` headers (HIT/MISS) and `ETag` headers for conditional 304 responses.

### Images

[](#images)

#### Image Proxy

[](#image-proxy)

Transform and optimize images on-the-fly:

```
GET /img/{modifiers}/{image_url}
```

**Examples:**

# Resize to 300px width, convert to WebP
https://api.bysages.com/img/w\_300,f\_webp/https://example.com/image.jpg

# Create 200x200 square thumbnail
https://api.bysages.com/img/s\_200x200,q\_80/https://example.com/image.png

# Smart crop with high quality
https://api.bysages.com/img/w\_400,h\_300,c\_fill,q\_95/https://example.com/photo.jpg

**Supported Modifiers:**

*   `w_XXX` - Width
*   `h_XXX` - Height
*   `s_XXXxYYY` - Size (width x height)
*   `f_FORMAT` - Format (webp, jpg, png, avif)
*   `q_XXX` - Quality (1-100)
*   `c_MODE` - Crop mode (fill, fit, pad)

**Performance Features:**

*   Redis caching for optimal performance
*   Automatic format optimization and compression

#### Screenshot Capture

[](#screenshot-capture)

Capture website screenshots with Playwright-aligned options:

```
GET /screenshot?url={website_url}&options
```

**Examples:**

# Basic screenshot
https://api.bysages.com/screenshot?url=https://example.com

# JPEG with custom quality
https://api.bysages.com/screenshot?url=https://example.com&type=jpeg&quality=80

# Mobile screenshot with custom viewport
https://api.bysages.com/screenshot?url=https://example.com&width=375&height=667&mobile=true

# Full page capture
https://api.bysages.com/screenshot?url=https://example.com&fullPage=true

# Clip region (x,y,width,height)
https://api.bysages.com/screenshot?url=https://example.com&clip=0,0,400,300

# Disable animations for clean capture
https://api.bysages.com/screenshot?url=https://example.com&animations=disabled

# Custom CSS scale
https://api.bysages.com/screenshot?url=https://example.com&scale=css

# Inject custom stylesheet
https://api.bysages.com/screenshot?url=https://example.com&style=body{background:red}

**Parameters:**

| Parameter | Description | Default |
| --- | --- | --- |
| url | Website URL (required) | - |
| width | Viewport width (100-2560) | 1280 |
| height | Viewport height (100-1440) | 720 |
| type | Output format (png, jpeg) | png |
| quality | Image quality, 1-100 (jpeg only) | - |
| fullPage | Capture full scrollable page (true/false) | false |
| clip | Clip region as x,y,width,height | - |
| scale | Screenshot scale (css for CSS pixels, device for device pixels) | device |
| animations | Animation handling (disabled or allow) | disabled |
| caret | Text caret (hide or initial) | hide |
| style | Custom CSS stylesheet to apply | - |
| timeout | Navigation timeout in ms (1000-30000) | 10000 |
| mobile | Mobile viewport (true/false) | false |
| darkMode | Dark mode (true/false) | false |
| waitUntil | Navigation wait condition (load, domcontentloaded, networkidle) | load |
| delay | Additional delay in ms after page load (0-10000) | - |

**Performance Notes:**

*   Screenshots are cached for 1 day with rate limiting
*   Identical requests return cached results with sub-second response times
*   Screenshots wait briefly for fonts and images, and freeze animations by default
*   Moli renders full-page captures as one bitmap; `SCREENSHOT_MAX_PIXELS` rejects oversized captures
*   `BROWSER_MAX_CONCURRENCY` is cluster-wide with Redis and includes a bounded queue

#### Open Graph Images

[](#open-graph-images)

Generate dynamic OG images:

```
GET /og?title={title}&description={description}
```

**Examples:**

# Basic OG image
https://api.bysages.com/og?title=Welcome&description=Get%20started%20with%20Lens

# Custom styling
https://api.bysages.com/og?title=Hello%20World&theme=dark&fontSize=72&width=1200&height=630

**Caching:**

*   Generated OG images are cached for 24 hours
*   Identical requests with same parameters return cached results instantly

### Web Services

[](#web-services)

#### Reader (Content Extraction)

[](#reader-content-extraction)

Extract rendered Markdown or HTML content from any URL:

```
GET /reader?url={website_url}&options
```

**Examples:**

# Extract rendered Markdown (default)
https://api.bysages.com/reader?url=https://github.com/bysages/lens

# Get HTML instead
https://api.bysages.com/reader?url=https://github.com/bysages/lens&format=html

# Wait for full page load
https://api.bysages.com/reader?url=https://github.com/bysages/lens&waitUntil=load

**Parameters:**

| Parameter | Description | Default |
| --- | --- | --- |
| url | Website URL (required) | - |
| format | Output format (html, markdown) | markdown |
| waitUntil | Navigation wait condition (load, domcontentloaded, networkidle) | domcontentloaded |
| delay | Additional delay in ms after page load (0-10000) | - |
| timeout | Navigation timeout in ms (1000-30000) | 10000 |

#### Favicon Extraction

[](#favicon-extraction)

Extract high-quality favicons:

```
GET /favicon?url={website_url}&size={size}
```

**Examples:**

# Extract favicon
https://api.bysages.com/favicon?url=github.com

# Custom size
https://api.bysages.com/favicon?url=github.com&size=64

**Features:**

*   Smart favicon extraction from multiple sources (PWA manifests, Apple touch icons, HTML tags)
*   30-day server-side caching with ETag/304 support
*   Automatic fallback to generated favicon if none found

#### Gravatar Proxy

[](#gravatar-proxy)

Get Gravatar avatars by email, MD5 hash, or path. All query parameters (except `email`/`hash`) are forwarded directly to Gravatar. You can switch from Gravatar by simply replacing the URL prefix.

```
GET /gravatar/{md5}?{gravatar_params}
GET /gravatar?email={email}
GET /gravatar?hash={md5}&{gravatar_params}
```

**Examples:**

# By path (drop-in replacement for Gravatar URLs)
https://api.bysages.com/gravatar/{md5}?size=200

# By email
https://api.bysages.com/gravatar?email=user@example.com

# By MD5 hash
https://api.bysages.com/gravatar?hash={md5}

# With Gravatar parameters
https://api.bysages.com/gravatar?email=user@example.com&size=200&default=identicon&rating=pg

**Parameters:**

*   `{md5}` - MD5 hash in URL path (drop-in replacement mode)
*   `email` - Email address (will be MD5 hashed automatically)
*   `hash` - Pre-computed MD5 hash (alternative to email)
*   All other parameters are forwarded to [Gravatar API](https://docs.gravatar.com/api/images/)

#### MCP Server

[](#mcp-server)

Model Context Protocol server exposing all Lens services as tools via JSON-RPC 2.0 over HTTP.

```
POST /mcp
```

**Setup with Claude Desktop** (`claude_desktop_config.json`):

{
  "mcpServers": {
    "lens": {
      "url": "https://api.bysages.com/mcp"
    }
  }
}

**Available Tools:**

| Tool | Description | Returns |
| --- | --- | --- |
| reader | Extract article content from a URL (Markdown/HTML) | text |
| screenshot | Capture a screenshot of a website | image |
| favicon | Extract a website's favicon | image |
| og | Generate a dynamic Open Graph image | image |
| gravatar | Get a Gravatar avatar by email or hash | image |
| img | Transform and optimize an image (resize, crop, convert) | image |

### Fonts

[](#fonts)

#### Font Service

[](#font-service)

Google Fonts compatible API with both v1 and v2 endpoints:

```
GET /css?family={font_family}&display=swap
GET /css2?family={font_family}&display=swap
```

**API Differences:**

| Feature | /css (v1) | /css2 (v2) |
| --- | --- | --- |
| Multiple fonts | family=A\|B (pipe separator) | family=A&family=B (repeated parameter) |
| Style syntax | FontName:400,700 (old) | FontName:wght@400;700 (new, strict) |
| Variable fonts | ❌ Not supported | ✅ Full support with ranges (wght@200..900) |
| Base URL | fonts.googleapis.com/css | fonts.googleapis.com/css2 |

**Parameters:**

*   `family` - Font family name (required)
*   `display` - Font display strategy (default: swap)
*   `subset` - Font subset (default: latin)
*   `provider` - Font provider (google, bunny, fontshare, fontsource, default: google)
*   `proxy` - Use proxy for font files (true/false, default: false)

**Examples:**

# Basic font (v1 API - pipe separator)
https://api.bysages.com/css?family=Roboto:wght@400;700|Open+Sans:wght@300;400;600&display=swap

# Multiple fonts (v2 API - repeated family parameter)
https://api.bysages.com/css2?family=Inter:wght@400;700&family=Roboto:wght@300;400&display=swap

# Variable fonts with weight range (v2 only)
https://api.bysages.com/css2?family=Inter:wght@200..800&display=swap

# Font metadata
https://api.bysages.com/webfonts?sort=popularity&category=sans-serif

**Recommendations:**

*   Use `/css` for **legacy compatibility** with old Google Fonts syntax
*   Use `/css2` for **modern applications** with variable fonts and better optimization
*   Both endpoints support all font providers (Google, Bunny, Fontshare, Fontsource)

## 🏗️ Architecture

[](#️-architecture)

Lens is built with modern web standards and follows clean architecture principles:

### Core Technologies

[](#core-technologies)

*   **Runtime**: Node.js 22+ with Nitro
*   **Language**: TypeScript with strict type safety
*   **Image Proxy**: IPX with Sharp
*   **Browser Automation**: [Moli](https://github.com/lexmount/moli) headless browser driven over CDP by Playwright
*   **Caching**: Redis with unstorage

### Design Principles

[](#design-principles)

*   **KISS (Keep It Simple)**: Simple, focused solutions over complex abstractions
*   **DRY (Don't Repeat Yourself)**: Shared utilities and configurations
*   **Graceful Degradation**: Fallbacks for missing dependencies
*   **Type Safety**: Comprehensive TypeScript coverage
*   **Performance First**: Optimized for speed and efficiency

## 🚀 Deployment

[](#-deployment)

### Prerequisites

[](#prerequisites-1)

*   **Node.js** 22+ runtime
*   **pnpm** 10+ package manager
*   **Redis** (bundled in Docker Compose; optional for other setups)

### Production Deployment

[](#production-deployment)

# Build the application
pnpm run build

# Start production server
pnpm run preview

### Docker Deployment (Recommended)

[](#docker-deployment-recommended)

#### Option 1: Using Docker Compose

[](#option-1-using-docker-compose)

# Copy environment variables
cp .env.example .env

# Edit .env file to configure your settings
# nano .env

# Start the application
docker-compose up -d

# View logs
docker-compose logs -f app

# Stop the application
docker-compose down

The application will be available at `http://localhost:3000`

`GET /healthz` reports liveness including browser (CDP) availability — the compose healthcheck and orchestrator probes should point at it.

The stack includes:

*   **Lens** application on port 3000, with the Moli browser running inside the container
*   **Redis** for distributed caching, bounded with LRU eviction (512 MB)

#### Option 2: Using Docker Run

[](#option-2-using-docker-run)

# Pull the official image
docker pull bysages/lens:latest

# Run container with host network (recommended for accurate IP logging);
# --init is needed because the image runs Moli alongside Node
docker run -d \\
  --name lens \\
  --network host \\
  --init \\
  --env-file .env \\
  --restart unless-stopped \\
  bysages/lens:latest

# View logs
docker logs -f lens

# Stop the container
docker stop lens
docker rm lens

#### Option 3: Build from Source

[](#option-3-build-from-source)

# Build Docker image
docker build -t lens .

# Run container
docker run -d \\
  --name lens \\
  --network host \\
  --init \\
  --env-file .env \\
  --restart unless-stopped \\
  lens

### Environment Variables

[](#environment-variables)

All configurations are optional:

# Image Proxy Security
ALLOWED\_DOMAINS\=example.com,cdn.example.com

# Set only when requests arrive through a trusted reverse proxy
TRUST\_PROXY\=1

# Caching (significantly improves performance)
REDIS\_URL\=redis://localhost:6379

# Browser: Moli CDP endpoint (defaults to http://127.0.0.1:9222)
BROWSER\_CDP\_ENDPOINT\=http://127.0.0.1:9222

# Browser: cluster-wide maximum with Redis (process-local fallback otherwise)
BROWSER\_MAX\_CONCURRENCY\=4

# Maximum wait for a global browser slot (ms)
BROWSER\_QUEUE\_TIMEOUT\=15000

# Reject full-page captures above this CSS pixel count
SCREENSHOT\_MAX\_PIXELS\=24000000

# Cluster: worker count (Docker defaults to 2; raw Nitro defaults to CPU count)
NITRO\_CLUSTER\_WORKERS\=2

# Moli server flags used by the Docker entrypoint
# (--block-private-networks is always enforced by the entrypoint)
MOLI\_FLAGS\=\--layout --resource --http-cache-dir=/tmp/lens-cache/moli-http --http-max-concurrent=32 --http-max-total-connections=64

See `.env.example` for all available options.

## 📄 License

[](#-license)

MIT License - see [LICENSE](/bysages/lens/blob/main/LICENSE) file for details.

* * *

Built with ❤️ by [By Sages](https://github.com/bysages)