Architecture
imgx is an HTTP image proxy built for concurrent request handling. It fetches images from a configurable origin, transforms them with libvips, and serves results through a multi-tier cache.
Request flow
Module map
crates/
├── imgx-vips/ Hand-rolled libvips FFI — the crate's unsafe/audit boundary.
│ └── src/
│ ├── ffi.rs Raw extern "C" declarations for the libvips/glib C surface.
│ ├── image.rs Safe RAII VipsImage wrapper (load, resize, effects, encode).
│ └── error.rs VipsError (thiserror).
│
└── imgx/ The binary. forbid(unsafe_code) -- everything unsafe lives in imgx-vips.
└── src/
├── main.rs Entry point. Loads config, initializes libvips, starts the server.
├── server.rs axum router, request dispatch, AppState (config/cache/origin/stats).
├── router.rs URL routing. Parses paths into a Route enum.
├── config.rs IMGX_* env var loading and validation.
│
├── transform/
│ ├── params.rs TransformParams struct and query string parser.
│ ├── negotiate.rs Accept header parsing and format negotiation.
│ └── pipeline.rs Probe → decide → reload → resize → effects → encode.
│
├── http/
│ ├── response.rs Content-Type, ETag, Cache-Control, 304 logic.
│ └── errors.rs Structured HTTP errors with JSON serialization.
│
├── cache/
│ ├── mod.rs Cache trait (async, RPITIT) and cache key builder.
│ ├── memory.rs In-memory LRU cache (parking_lot::RwLock).
│ ├── noop.rs No-op cache (used when caching is disabled).
│ ├── r2.rs R2/S3-backed cache (L2 persistent layer).
│ └── tiered.rs L1 plus L2 composition (memory → R2), generic over both.
│
├── origin/
│ ├── source.rs URL builder for HTTP origin.
│ ├── fetcher.rs HTTP client for origin fetches (reqwest).
│ └── r2.rs R2-backed origin fetcher.
│
└── s3/
└── client.rs S3 client (GET/PUT/DELETE/HEAD) via rusty-s3 presigned URLs + reqwest.Cache tiers
imgx uses a Cache trait (async methods, impl Future return position) with multiple backends. The backend set is closed (Memory/Noop/R2/Tiered), so the server selects a concrete AppCache enum variant at startup based on config rather than using dyn Cache — no boxing, no object-safety cost:
Read path: L1 hit → return. L1 miss → check L2 → promote to L1 → return.
Write path: L1 is written synchronously (fast). L2 is written asynchronously via tokio::spawn to keep the R2 upload off the response path.
Write fallback: If a cache write is skipped (for example, cache disabled or variant too large for L1), the image response is still returned uncached.
Delete path: Delete from both L1 and L2.
When IMGX_CACHE_ENABLED=false, a NoopCache replaces all cache operations with no-ops.
When IMGX_ORIGIN_TYPE=http (no R2 configured), only the L1 memory cache is active.
Origin backends
HTTP (IMGX_ORIGIN_TYPE=http)
Appends the request path to IMGX_ORIGIN_BASE_URL:
Request: GET /image/w=400/photos/cat.jpg
Origin: GET https://images.example.com/photos/cat.jpgR2 (IMGX_ORIGIN_TYPE=r2)
Fetches from a Cloudflare R2 bucket using AWS SigV4-signed presigned URLs:
Request: GET /image/w=400/photos/cat.jpg
Origin: S3 GET from bucket "originals", key "photos/cat.jpg"With R2 origin, the tiered cache uses a second R2 bucket (IMGX_R2_BUCKET_VARIANTS) as the L2 persistent layer.
Transform pipeline
The pipeline uses a probe-decide-reload flow for animation-aware processing:
1. Probe
Loads only the first frame to detect animation metadata via n-pages.
2. Decide
Determines whether to produce animated output based on: animation mode, frame extraction, output format support, pixel budget, and the Accept header.
3. Reload
For animated output: drops the probe image (Rust's ownership handles this automatically on reassignment) and reloads all frames stacked vertically. Clamps frame count to max_frames. For static output: no-op.
4. Resize
Uses vips_thumbnail_image with fit mode mapping:
fit value | libvips behavior |
|---|---|
contain | VIPS_SIZE_DOWN — scale down, preserve aspect ratio |
cover | Crop to fill using gravity for the crop anchor |
fill | VIPS_SIZE_FORCE — stretch to exact dimensions |
inside | VIPS_SIZE_DOWN — same as contain |
outside | VIPS_SIZE_UP — scale up to cover dimensions |
pad | Resize like contain, then embed into target canvas using background color |
For animated images with fit=cover, a two-step resize (scale without crop, then per-frame crop + reassemble) works around a libvips upstream bug that corrupts frame boundaries on stacked animated buffers — see docs/INVARIANTS.md INV-2.
5. Effects
Applied in order: sharpen → blur → brightness/contrast → gamma → saturation.
6. Encode
| Format | Encoder | Quality |
|---|---|---|
| JPEG | vips_jpegsave_buffer | 1–100 |
| PNG | vips_pngsave_buffer | Compression level 6 (fixed) |
| WebP | vips_webpsave_buffer | 1–100 |
| AVIF | vips_heifsave_buffer | 1–100 |
| GIF | vips_gifsave_buffer | Palette-based |
A pre-encode check on GIF output validates that page-height metadata evenly divides the total image height, resetting to single-frame if not — this avoids a libvips GIF-encoder crash on stale metadata after resize/effects (INV-3).
Error handling
Errors serialize to JSON:
{"error":{"status":404,"message":"Not Found","detail":"image not found at origin"}}| Condition | HTTP status |
|---|---|
| Invalid transform parameters | 400 Bad Request |
| Transform values out of range | 422 Unprocessable Entity |
| Image not found at origin | 404 Not Found |
| Origin server timed out | 504 Gateway Timeout |
| Image exceeds size limit | 413 Payload Too Large |
| Origin fetch failed | 502 Bad Gateway |
| Server at connection or transform capacity | 503 Service Unavailable |
Concurrency model
imgx runs on axum/tokio rather than a fixed thread pool. Each incoming request is handled by an async task on the tokio runtime; CPU-bound libvips work is dispatched via tokio::task::spawn_blocking, gated by a Semaphore sized to std::thread::available_parallelism().
Two independent backpressure mechanisms protect the server, each bounding a different resource:
- Connection-level (
IMGX_SERVER_MAX_CONNECTIONS) — A towerconcurrency_limit+load_shedlayer on the router bounds total concurrent in-flight requests. At capacity, new requests get503immediately rather than queueing. This is the direct equivalent of the prior Zig implementation's fixed 256-thread pool + atomic connection counter, expressed as router middleware instead of a hand-rolled accept-loop check. - Transform-level (vips semaphore) — When all
spawn_blockingpermits are held, a new transform request is separately rejected with503rather than queued unboundedly. A burst of cache-hit requests never touches this limiter at all, since it only gates the CPU-bound libvips work.
Both mechanisms preserve the same invariant the Zig original had — "reject new work under saturation rather than queueing it unboundedly" — just split across the two resources (connections vs. CPU-bound transform work) that axum's async model actually needs to bound separately.
- Thread-safe caches —
MemoryCacheusesparking_lot::RwLock(held only across synchronous in-memory operations, never across an.await).R2Cacheperforms its own async I/O per call, no shared mutable state to lock. - Async L2 writes — The persistent R2 cache layer is written via
tokio::spawnafter the L1 write completes, keeping origin upload latency off the critical path. - libvips threading — libvips uses its own internal thread pool for image operations within each
spawn_blockingtask. - Horizontal scaling — Run multiple instances behind a load balancer. Each instance maintains its own L1 cache. The shared R2 L2 cache provides cross-instance persistence.
- Graceful shutdown — The server drains in-flight requests on
SIGTERM/Ctrl+C before exiting, important for clean pod termination on Kubernetes/GKE.