Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ PORT=3000
# disk - Persist to disk only (survives restarts)
# memory - In-memory only (fastest, lost on restart)
# hybrid - Memory (L1) + Disk (L2) for speed and persistence
# redis - Redis/Valkey (uses Bun's built-in Redis client)
# none - No caching
CACHE_MODE=disk
CACHE_DIR=./cache # Directory for disk cache (disk/hybrid modes)
Expand All @@ -23,5 +24,11 @@ ALLOW_SELF_REFERENCE=false # Allow /image to fetch from own /og endpoint (for
MAX_IMAGE_SIZE=10485760 # Max image size in bytes (default: 10MB)
REQUEST_TIMEOUT=30000 # Request timeout in ms (default: 30s)

# Redis cache settings (only used when CACHE_MODE=redis)
REDIS_URL="redis://localhost:6379" # Redis connection URL — quote if credentials contain special chars
REDIS_KEY_PREFIX=ps: # Key prefix to namespace cache entries
REDIS_CONNECTION_TIMEOUT=5000 # Connection timeout in ms
REDIS_MAX_RETRIES=10 # Max reconnection attempts

# Custom OG Templates
TEMPLATES_DIR=./templates # Directory for custom OG image templates (JSON files)
17 changes: 10 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,27 @@ jobs:
- name: Install dependencies
run: bun install

- name: Run Biome
run: bunx biome check .
- name: Run oxlint
run: bun run lint

typecheck:
name: Type Check
- name: Check formatting
run: bun run format:check

build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Install Bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: bun install

- name: Run TypeScript check
run: bunx tsc --noEmit
- name: Build with tsdown
run: bun run build

test:
name: Test
Expand Down
14 changes: 14 additions & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"useTabs": false,
"tabWidth": 2,
"printWidth": 80,
"singleQuote": false,
"jsxSingleQuote": false,
"quoteProps": "as-needed",
"trailingComma": "all",
"semi": true,
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"ignorePatterns": []
}
8 changes: 8 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "error",
"suspicious": "warn"
},
"ignorePatterns": ["node_modules", "dist", "cache"]
}
112 changes: 86 additions & 26 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,63 +8,123 @@ Image processing microservice with OG image generation.
- **Framework**: Elysia (type-safe HTTP framework)
- **Image Processing**: Sharp (libvips bindings)
- **OG Generation**: Satori + resvg-js (SVG-based, no headless browser)
- **Linting/Formatting**: Biome
- **Linting**: oxlint (fast Rust-based linter)
- **Formatting**: oxfmt (Prettier-compatible Rust formatter)
- **Building**: tsdown (TypeScript bundler based on Rolldown)
- **Config Validation**: @sinclair/typebox (runtime schema validation)

## Commands

```bash
bun install # Install dependencies
bun run dev # Start with hot reload
bun run start # Start production server
bun run start:cluster # Start with clustering
bun test # Run all tests
bun test tests/unit # Run unit tests only
bun run lint # Lint with oxlint
bun run lint:fix # Lint and auto-fix
bun run format # Format with oxfmt
bun run format:check # Check formatting without writing
bun run build # Build with tsdown
```

## Project Structure

```
```text
src/
├── index.ts # Entry point, Elysia server setup
├── config.ts # Environment configuration
├── index.ts # Entry point — server setup, CORS, cache init, shutdown
├── config.ts # Env config with TypeBox schema validation
├── constants.ts # Named constants (timeouts, limits, defaults)
├── middleware/
│ ├── origin-validator.ts # Origin/Referer validation guard (subdomain-aware)
│ └── error-handler.ts # Shared Elysia error handler (PixelServeError → JSON)
├── routes/
│ ├── image.ts # GET /image - image transformations
│ ├── og.ts # GET /og - OG image generation
│ └── health.ts # GET /health - health check
│ ├── image.ts # GET /image image transformations
│ ├── og.ts # GET /og OG image generation
│ └── health.ts # GET /health health check
├── services/
│ ├── image-processor.ts # Sharp transformations
│ ├── image-fetcher.ts # Remote image fetching with SSRF protection
│ ├── cache.ts # Disk/memory caching
│ ├── og-generator.ts # Satori rendering
│ ├── custom-templates.ts # JSON template builder
│ └── fonts.ts # Dynamic Google Fonts loading
│ ├── image-processor.ts # Orchestrator: fetch → crop → resize → adjust → watermark → output
│ ├── image-fetcher.ts # Remote image fetching with SSRF protection + redirect validation
│ ├── cache.ts # Multi-backend caching (disk/memory/hybrid/redis/none)
│ ├── og-generator.ts # Satori rendering pipeline
│ ├── custom-templates.ts # JSON template builder for OG images
│ ├── fonts.ts # Dynamic Google Fonts loading + caching
│ └── transforms/ # Individual image transform steps
│ ├── crop.ts
│ ├── resize.ts
│ ├── adjustments.ts # rotate, flip, flop, brightness, saturation, grayscale, tint, blur, sharpen, trim
│ ├── watermark.ts # Text (via Satori) and image watermarks with positioning
│ └── output.ts # Format conversion (webp, avif, png, jpg, gif)
├── utils/
│ ├── url-validator.ts # SSRF prevention (blocks private IPs)
│ └── errors.ts # Custom error classes
└── types/index.ts # TypeScript interfaces
│ ├── url-validator.ts # SSRF prevention (private IP blocking, DNS resolution, domain allowlist)
│ └── errors.ts # Error hierarchy: PixelServeError → Validation/Fetch/Timeout/Forbidden/NotFound/ImageProcessing
└── types/index.ts # TypeScript interfaces (ImageParams, OGParams, etc.)

templates/ # Custom JSON templates for OG images
tests/
├── unit/ # Unit tests
├── unit/ # Unit tests (bun:test)
└── integration/ # API integration tests
```

## Key APIs

- `GET /image?url=<source>&w=<width>&h=<height>&format=<webp|avif|png|jpg>` - Transform remote images
- `GET /og?title=<title>&description=<desc>&template=<name>` - Generate OG images
- `GET /og/templates` - List available templates
- `GET /health` - Health check with cache stats
- `GET /image?url=<source>&w=<width>&h=<height>&format=<webp|avif|png|jpg>` — Transform remote images
- `GET /og?title=<title>&description=<desc>&template=<name>` — Generate OG images
- `GET /og/templates` — List available templates with schema docs
- `GET /health` — Health check with cache stats

## Architecture Patterns

### Error handling

- All domain errors extend `PixelServeError` (in `utils/errors.ts`) with `statusCode` and `code` fields.
- Routes use the shared `createErrorHandler()` from `middleware/error-handler.ts` — do NOT duplicate error handling inline.
- Cache write errors are fire-and-forget (logged but don't fail the request).

### Middleware

- Origin validation and error handling live in `src/middleware/`. Elysia hooks are created via factory functions (`createOriginGuard()`, `createErrorHandler()`) for testability.
- Tests import and test the real middleware — never duplicate implementation in test files.

### Image processing pipeline

- `image-processor.ts` is a thin orchestrator. Each transform step is in `services/transforms/`. Add new transforms as separate files there.
- Pipeline order: fetch → auto-orient → crop → resize → adjustments → watermark → output format.

### SSRF protection

- `url-validator.ts` blocks private IPs, loopback, and link-local addresses via DNS resolution.
- `image-fetcher.ts` handles redirects manually (max 5 hops) and re-validates each redirect target through `validateUrl()` to prevent redirect-to-private-IP attacks. Never use `redirect: "follow"`.

### Caching

- `cache.ts` abstracts multiple backends behind `getCached()`/`setCache()`. Cache mode is set via `CACHE_MODE` env var.
- Cache keys are SHA256 hashes of sorted params (via `generateCacheKey()`).

### Config

- `config.ts` parses env vars and validates against a TypeBox schema. Bun auto-loads `.env` — no dotenv needed.
- Named constants (timeouts, limits) go in `constants.ts`, not inline as magic numbers.

## Code Style

- Use Biome for linting/formatting: `bunx biome check --write .`
- Double quotes for strings
- Space indentation
- Organize imports automatically
- **Formatter**: oxfmt (`bun run format`). Double quotes, trailing commas, semicolons, space indentation.
- **Linter**: oxlint (`bun run lint`).
- **Imports**: Use `import type` for type-only imports. Keep imports organized: external → internal (config/constants → middleware → services → types → utils).
- **No `any`**: Use proper types or narrowing. Avoid `as` casts unless structurally necessary (document why).
- **Errors**: Throw typed errors from `utils/errors.ts` — never throw plain strings or generic `Error`.

## Bun-Specific Notes

- Bun auto-loads `.env` files (no dotenv needed)
- Use `Bun.$\`cmd\`` for shell commands
- Use `Bun.file()` over `node:fs` when possible
- Tests use `bun:test` (import { test, expect, describe } from "bun:test")
- Tests use `bun:test` (`import { test, expect, describe } from "bun:test"`)
- Redis uses Bun's built-in `RedisClient` (not ioredis)

## Testing

- Tests live in `tests/unit/` and `tests/integration/`.
- Unit tests mock external dependencies; integration tests use Elysia's `app.handle()` for in-process HTTP testing.
- Always run `bun test` after changes. Run `bun run lint` and `bun run format` before committing.
8 changes: 4 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ COPY package.json bun.lock ./
# Install all dependencies (including dev)
RUN bun install --frozen-lockfile

# Copy source code
# Copy source code and config
COPY src ./src
COPY tsconfig.json ./
COPY tsconfig.json tsdown.config.ts ./

# Type check (optional but recommended)
RUN bun x tsc --noEmit || true
# Build with tsdown
RUN bun run build

# ============================================
# Stage 3: Production runtime
Expand Down
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ Instead of creating template files, you can pass the entire template configurati
**Encoding Options:**

1. **Base64** (recommended for complex templates):

```bash
# Encode your template
echo '{"layout":{"elements":[{"type":"text","content":"{{title}}","fontSize":48}]}}' | base64
Expand All @@ -399,6 +400,7 @@ Instead of creating template files, you can pass the entire template configurati
```

2. **URL-safe Base64** (uses `-` and `_` instead of `+` and `/`):

```bash
/og?config=eyJsYXlvdXQiOnsiZWxlbWVudHMiOlt7InR5cGUiOiJ0ZXh0IiwiY29udGVudCI6Int7dGl0bGV9fSIsImZvbnRTaXplIjo0OH1dfX0-&title=Hello
```
Expand All @@ -424,23 +426,23 @@ const template = {
content: "{{title}}",
fontSize: 56,
fontWeight: 700,
color: "#ffffff"
color: "#ffffff",
},
{
type: "spacer",
size: 20,
showIf: "description"
showIf: "description",
},
{
type: "text",
content: "{{description}}",
fontSize: 24,
color: "#ffffff",
opacity: 0.9,
showIf: "description"
}
]
}
showIf: "description",
},
],
},
};

// Encode and use
Expand Down Expand Up @@ -540,6 +542,7 @@ docker run -e CLUSTER_WORKERS=4 ghcr.io/climactic/pixelserve:latest
```

**Notes:**

- Clustering requires Linux (uses `SO_REUSEPORT`). On macOS/Windows, it falls back to single process mode with a warning.
- Each worker maintains its own memory cache; disk cache is shared across all workers.
- Crashed workers are automatically respawned to maintain availability.
Expand Down
34 changes: 0 additions & 34 deletions biome.json

This file was deleted.

Loading