A high-performance Open Graph image generation service for the pawn-docgen project.
The og-gen service is a TypeScript application that generates dynamic Open Graph (OG) images for social media sharing. It renders beautiful, customizable images for documentation pages using React components and the Takumi image rendering library.
Features:
- 🎨 Dynamic OG image generation from query parameters
- 🔐 HMAC-based security verification
- 🌓 Light and dark theme support
- ⚡ Blazing fast with Bun runtime
- 🎭 Preview mode for development
- 💾 Built-in image caching
- 🎯 1200x630px optimized images
The og-gen service follows Clean Architecture principles with strict layer separation:
src/
├── domain/ # ← Business logic (no external dependencies)
│ ├── entities/ # OgImage, Theme business objects
│ └── interfaces/ # ImageRenderer, SecurityProvider abstractions
│
├── application/ # ← Use case handlers (CQS Query layer)
│ └── queries/ # GetOgImageQuery, GetOgPreviewQuery (read operations)
│ ├── get-og-image/
│ │ ├── get-og-image.query.ts # Query object (input)
│ │ └── get-og-image.handler.ts # Query handler (business logic)
│ └── get-og-preview/
│ ├── get-og-preview.query.ts
│ └── get-og-preview.handler.ts
│
├── infrastructure/ # ← Technical implementations (external tools)
│ ├── crypto/ # HMAC security provider
│ │ └── hmac-security.provider.ts
│ ├── fonts/ # Font loading service
│ │ └── font-loader.service.ts
│ └── renderer/ # Pluggable renderers
│ ├── takumi.renderer.ts # Rust-based PNG rendering
│ └── html.renderer.ts # HTML preview rendering
│
├── presentation/ # ← HTTP layer (controllers & components)
│ ├── controllers/ # HTTP request handlers
│ │ ├── og.controller.ts # /og and /og/preview routes
│ │ └── health.controller.ts # /health endpoint
│ └── components/ # React components
│ ├── og-layout.component.tsx # HTML structure
│ └── og-template.component.tsx # Image content template
│
└── main.ts # ← Application entry point
Presentation (HTTP) ↓
↓
Application (CQS Handlers) ↓
↓
Domain (Business Logic) ↓
↓
Infrastructure (Implementations)
Key Principle: Domain layer knows nothing about HTTP, databases, or frameworks.
The application layer uses CQS pattern:
- Queries = read operations that return data without side effects
- Commands = write operations (not used in this service, but pattern is extensible)
Example - Query Flow:
// 1. Query object (contains input data)
export class GetOgImageQuery {
constructor(
public readonly image: OgImage,
public readonly signature: string,
) {}
}
// 2. Query handler (implements use case logic)
export class GetOgImageHandler {
constructor(
private readonly renderer: ImageRenderer<Uint8Array>,
private readonly security: SecurityProvider,
) {}
async execute(query: GetOgImageQuery): Promise<Uint8Array> {
// Domain logic here
const { image, signature } = query;
const isValid = this.security.verify(image.title, signature);
if (!isValid) throw new Error("Invalid signature");
return await this.renderer.render(image);
}
}
// 3. Controller (HTTP layer calls handler)
async render(req: Request): Promise<Response> {
const query = new GetOgImageQuery(ogImage, signature);
const imageBuffer = await this.getOgImageHandler.execute(query);
return new Response(Buffer.from(imageBuffer), {
headers: { "Content-Type": "image/png" }
});
}Benefits:
- Testable: Mock query and handler independently
- Reusable: Handlers work with any HTTP framework
- Clear intent: Query names describe operations
- Extensible: Easy to add Commands for write operations
The project follows NestJS file naming patterns for clarity and consistency:
| Type | Pattern | Example |
|---|---|---|
| Domain Entity | {name}.entity.ts |
og-image.entity.ts |
| Interface/Contract | {name}.interface.ts |
image-renderer.interface.ts |
| Implementation Provider | {name}.provider.ts |
hmac-security.provider.ts |
| Service | {name}.service.ts |
font-loader.service.ts |
| Controller | {name}.controller.ts |
og.controller.ts |
| Query Object | {name}.query.ts |
get-og-image.query.ts |
| Query Handler | {name}.handler.ts |
get-og-image.handler.ts |
| React Component | {name}.component.tsx |
og-template.component.tsx |
| Renderer | {name}.renderer.ts |
takumi.renderer.ts, html.renderer.ts |
Directory Structure Rules:
- Create subdirectories for feature grouping:
queries/get-og-image/ - Group related files together (query + handler in same folder)
- Use kebab-case for filenames, PascalCase for exported class names
# Server port (default: 3000)
PORT=3000
# HMAC secret for signature verification
OG_HMAC_SECRET=your-secret-key
# Enable HMAC verification (default: false)
CHECK_HMAC=true
# HMAC signature length (default: 8)
CHECK_HMAC_SYMBOLS=8
# Node environment
NODE_ENV=production # or 'development'See .env file for development defaults.
GET /og
Generates a PNG image for the specified parameters.
Query Parameters:
title(required) - Image title (max 86 chars, auto-truncated)subtitle- Image subtitle (max 450 chars, auto-truncated)tag- Tag/category badge (default: "Pawn")theme- Theme color ("dark" or "light", default: "dark")s- HMAC signature (required if CHECK_HMAC=true)
Response:
- Content-Type:
image/png - Cache-Control:
public, max-age=31536000, immutable(1 year)
Example:
GET /og?title=MyFunction&subtitle=Documentation&tag=API&theme=dark&s=abc123def456
GET /og/preview
Renders HTML preview of the OG image for development and testing.
Query Parameters: Same as /og (signature optional in preview mode)
Response:
- Content-Type:
text/html; charset=utf-8
Example:
GET /og/preview?title=MyFunction&subtitle=Documentation
GET /health
Service health endpoint.
Response:
{
"status": "ok",
"hmac_enabled": true
}When CHECK_HMAC=true, all /og requests require a valid HMAC signature.
Generation (PHP example):
$secret = getenv('OG_HMAC_SECRET');
$title = 'MyFunction';
$fullHash = hash_hmac('sha256', $title, $secret);
$signature = substr($fullHash, 0, 8); // CHECK_HMAC_SYMBOLS
$url = "/og?title=$title&s=$signature";Verification (TypeScript):
- Signature is validated against the
titleparameter - Timing-safe comparison prevents timing attacks
- Invalid signatures return 403 Forbidden
Dark Theme:
- Background: #141020
- Card: #1e1830
- Text: #e6e6f0
- Accent: #588cff
Light Theme:
- Background: #f5f6fa
- Card: white
- Text: #2c1e47
- Accent: #0b5ed7
- Font: IBM Plex Sans (Regular 400, SemiBold 600)
- Title: 48px SemiBold
- Subtitle: 24px Regular
- Tag: 14px Bold (uppercase)
Two font files are included in the root directory:
IBMPlexSans-Regular.ttf— Regular weight (400)IBMPlexSans-SemiBold.ttf— SemiBold weight (600)
These are loaded at runtime and cached for performance.
One of the key benefits of Clean Architecture is the ability to swap implementations easily. The og-gen service demonstrates this with multiple renderer implementations:
// domain/interfaces/image-renderer.interface.ts
export interface ImageRenderer<T> {
render(image: OgImage): Promise<T>;
}All renderers implement this single contract.
File: infrastructure/renderer/takumi.renderer.ts
export class TakumiRenderer implements ImageRenderer<Uint8Array> {
async render(image: OgImage): Promise<Uint8Array> {
// Uses Rust-compiled Takumi library for fast image rendering
// Returns PNG binary data
}
}- ✅ Fast (~50ms per image)
- ✅ Compiled Rust backend
- ✅ Production-ready
- ✅ Supports complex layouts (with Tailwind CSS out of the box)
File: infrastructure/renderer/html.renderer.ts
export class HtmlRenderer implements ImageRenderer<string> {
async render(image: OgImage): Promise<string> {
// Renders React component to HTML string
// Returns HTML markup for browser preview
}
}- ✅ Instant rendering
- ✅ No image generation overhead
- ✅ Great for development
- ✅ Uses React SSR (react-dom/server)
To add a custom renderer (e.g., Sharp, Puppeteer, Skia):
Step 1: Create implementation file
// infrastructure/renderer/sharp.renderer.ts
import sharp from "sharp";
import { ImageRenderer } from "../../domain/interfaces/image-renderer.interface";
import { OgImage } from "../../domain/entities/og-image.entity";
export class SharpRenderer implements ImageRenderer<Uint8Array> {
async render(image: OgImage): Promise<Uint8Array> {
const svg = await this.generateSvg(image);
return await sharp(Buffer.from(svg)).png().toBuffer();
}
private async generateSvg(image: OgImage): Promise<string> {
// SVG generation logic
}
}Step 2: Update dependency injection in main.ts
// main.ts
import { SharpRenderer } from "./infrastructure/renderer/sharp.renderer";
const imageRenderer = new SharpRenderer(); // Swap implementation!
const getOgImageHandler = new GetOgImageHandler(
imageRenderer,
securityProvider,
);That's it! The handler and controller work without changes.
✅ Testable: Mock any renderer for unit tests
✅ Swappable: Change rendering library without touching business logic
✅ Extensible: Add new renderers without breaking existing code
✅ Future-proof: Easy to switch to better libraries as they emerge
The project uses only 3 dependencies (plus TypeScript types):
{
"dependencies": {
"@takumi-rs/image-response": "^0.66.0", // Image rendering
"react": "^19.2.3", // Component framework
"react-dom": "^19.2.3" // SSR support
},
"devDependencies": {
"@types/bun": "^1.3.6", // Bun types
"@types/react": "^19.2.3", // React types
"@types/react-dom": "^19.2.3", // React-DOM types
"tailwindcss": "^4.1.18" // CSS utility (optional)
}
}No dependencies for:
- HTTP server (built into Bun)
- HMAC crypto (Bun crypto module)
- Font loading (Bun file API)
- Dependency injection (simple constructor injection)
- React rendering (built-in with react-dom/server)
Benefits:
- ⚡ Extremely fast startup (~100ms)
- 📦 Tiny container size (~50MB)
- 🔒 Minimal security surface
- 🚀 No npm package bloat
- 🎯 Clear dependency graph
- All-in-one: Built-in TypeScript, HTTP server, package manager
- Fast: 10x faster than Node.js for startup
- Minimal: No external dependencies for basic features
- Lightweight: Perfect for single-purpose services
- Modern: Native ES modules, top-level await, JSX support
cd docker/og-gen
# Install dependencies
bun install
# Development mode (watch mode)
bun run dev
# Production mode
bun run startServer runs on http://localhost:3000
The Clean Architecture makes testing straightforward:
// Example: Test GetOgImageHandler without HTTP/Bun
import { GetOgImageHandler } from "../src/application/queries/get-og-image/get-og-image.handler";
import { OgImage } from "../src/domain/entities/og-image.entity";
import { Theme } from "../src/domain/entities/theme.entity";
// Mock implementations for testing
class MockRenderer implements ImageRenderer<Uint8Array> {
async render(image: OgImage): Promise<Uint8Array> {
return new Uint8Array([1, 2, 3]); // Fake PNG data
}
}
class MockSecurityProvider implements SecurityProvider {
verify(data: string, hash: string): boolean {
return hash === "valid-hash";
}
}
// Test the handler
const renderer = new MockRenderer();
const security = new MockSecurityProvider();
const handler = new GetOgImageHandler(renderer, security);
const image = new OgImage({
title: "Test Function",
subtitle: "Test Description",
tag: "Pawn API",
theme: Theme.fromString("dark"),
});
const query = new GetOgImageQuery(image, "valid-hash");
const result = await handler.execute(query);
console.assert(result.length > 0, "Should return image data");Why this is great:
- ✅ No HTTP framework needed
- ✅ No async test runners required
- ✅ Easy to mock dependencies
- ✅ Fast execution
- ✅ Clear what's being tested
// Use HTML renderer in tests for instant feedback
const testRenderer = new HtmlRenderer();
const handler = new GetOgImageHandler(testRenderer, securityProvider);
// Or mock renderer for snapshot testing
class SnapshotRenderer implements ImageRenderer<Uint8Array> {
async render(image: OgImage): Promise<Uint8Array> {
// Return consistent data for snapshot comparison
return Buffer.from(
JSON.stringify({
title: image.title,
theme: image.theme.isDark() ? "dark" : "light",
}),
);
}
}# Without HMAC verification (development)
curl "http://localhost:3000/og/preview?title=TestFunction&subtitle=Test%20subtitle"
# Open in browser to see rendered preview# Health check
curl http://localhost:3000/health | jq
# Generate PNG with signature (if CHECK_HMAC=true)
curl "http://localhost:3000/og?title=MyFunc&theme=dark&s=abc123" \
-H "Accept: image/png" \
-o og-image.png
# Generate with preview (no signature needed)
curl "http://localhost:3000/og/preview?title=MyFunc&subtitle=Docs" > preview.html
open preview.html- Rendering: ~50ms per image (Takumi compiled library)
- Caching: Font files cached in memory after first load
- Cache-Control: Images cached for 1 year (immutable)
- NGINX: 30-day reverse proxy cache with stale-while-revalidate
docker build -t og-gen:latest .docker run -p 3000:3000 \
-e PORT=3000 \
-e OG_HMAC_SECRET=secret \
-e CHECK_HMAC=true \
-e NODE_ENV=production \
og-gen:latestog-gen:
build: ./docker/og-gen
restart: always
env_file: .env
ports:
- "3000:3000"The main www/template/header.php generates OG image URLs:
$OG_Params = [
'title' => $PageFunction['Function'],
'subtitle' => $PageFunction['Comment'],
'tag' => 'Pawn API',
'theme' => 'dark'
];
// Generate HMAC signature
$fullSignature = hash_hmac('sha256', $OG_Params['title'], getenv('OG_HMAC_SECRET'));
$signature = substr($fullSignature, 0, getenv('CHECK_HMAC_SYMBOLS'));
// Construct URL
$ogImageUrl = "/og?" . http_build_query($OG_Params) . "&s=" . $signature;- Ensure font files exist in the container root
- Verify Takumi library version compatibility
- Check secret matches in PHP and og-gen config
- Verify signature is calculated on
titleparameter only - Ensure signature length matches
CHECK_HMAC_SYMBOLS
Error: Font file not found: ./IBMPlexSans-Regular.ttf
Solution: Ensure TTF files are in docker/og-gen/ directory before building.