pixel-serve-server
Version:
A robust Node.js utility for handling and processing images. This package provides features like resizing, format conversion and etc.
577 lines (433 loc) โข 35.7 kB
Markdown
# Pixel Serve Server
**A modern, type-safe middleware** for processing, resizing, and serving images in Node.js applications. Built with **TypeScript**, powered by **Sharp**, and designed for secure production use with ESM & CJS bundles.
[](https://opensource.org/licenses/MIT)
[](https://www.npmjs.com/package/pixel-serve-server)
[](https://github.com/Hiprax/pixel-serve-server/actions/workflows/ci.yml)
[](https://github.com/Hiprax/pixel-serve-server/actions/workflows/codeql.yml)
[](https://codecov.io/gh/Hiprax/pixel-serve-server)
[](https://www.npmjs.com/package/pixel-serve-server)
[](https://www.typescriptlang.org/)
[](https://nodejs.org/)
## Features
- ๐ผ๏ธ **Dynamic resizing & formatting**: `jpeg`, `png`, `webp`, `gif`, `tiff`, `avif` with configurable width/height bounds and quality limits (SVG is **not** supported as an output format โ libvips/Sharp cannot encode SVG)
- ๐ **Secure source resolution**: Strict path validation, domain allowlists, and MIME type checks for network fetches
- ๐ **Fallbacks & private folders**: Built-in placeholder images plus async `getUserFolder` for private assets
- โก **Caching ready**: ETag + Cache-Control headers out of the box
- ๐งช **Type-safe & tested**: 100% TypeScript with Vitest coverage and exported Zod schemas
- โป๏ธ **Dual builds**: Works in both ESM and CommonJS environments
## Installation
Requires **Node.js 20 or newer** (Node 18 reached end-of-life on 2025-04-30; the build/test toolchain โ Sharp 0.34, Vitest 4, ESLint 10 โ now requires Node 20+).
```bash
npm install pixel-serve-server
```
## Quick Start
### Basic Setup (Express)
```typescript
import express from "express";
import { registerServe } from "pixel-serve-server";
import path from "node:path";
const app = express();
const serveImage = registerServe({
baseDir: path.join(__dirname, "../assets/images/public"),
});
app.get("/api/v1/pixel/serve", serveImage);
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});
```
> **ESM vs CJS โ `__dirname`.** The example above uses `__dirname`, which is a
> built-in only in **CommonJS** (`"type": "commonjs"` in `package.json`, or no
> `type` field). In **ECMAScript Modules** (`"type": "module"` or `.mjs` files)
> `__dirname` does **not** exist and the example will throw `ReferenceError:
> __dirname is not defined`. Derive it from `import.meta.url` instead:
>
> ```ts
> // CJS โ works out of the box, no extra code needed:
> // __dirname is a built-in module-scoped variable.
>
> // ESM โ derive it from import.meta.url:
> import { fileURLToPath } from "node:url";
> import { dirname } from "node:path";
> const __dirname = dirname(fileURLToPath(import.meta.url));
> ```
>
> Both forms produce the same string. Place the ESM derivation at the top of
> the entry file (before the `path.join(__dirname, โฆ)` call).
### Advanced Setup with All Options
```typescript
import express from "express";
import { registerServe } from "pixel-serve-server";
import path from "node:path";
const app = express();
const serveImage = registerServe({
// Required: Base directory for public images
baseDir: path.join(__dirname, "../assets/images/public"),
// Custom user ID handler
idHandler: (id: string) => `user-${id}`,
// Async function to resolve private folder paths.
// Returning an empty string (`""`) keeps the public `baseDir` โ the type
// signature is `string | Promise<string>` (no `null`).
getUserFolder: async (req, userId) => {
// Your logic to resolve user-specific folder
return `/private/users/${userId}`;
},
// Optional containment root. When set, the framework verifies that the
// path returned by `getUserFolder` resolves inside this directory and
// falls back to `baseDir` if it escapes (defense-in-depth realpath check).
getUserFolderRootDir: "/private/users",
// Your website's base URL (for treating internal URLs as local)
websiteURL: "example.com",
// Literal-string prefix stripped from internal URL pathnames. When set,
// it takes precedence over `apiRegex` and uses a plain startsWith + slice
// (recommended โ see "API Prefix and ReDoS Safety" below).
apiPrefix: "/api/v1/",
// Regex stripped from internal URL pathnames (ignored when `apiPrefix` is
// set). Must be a safe (non-ReDoS) regex.
apiRegex: /^\/api\/v1\//,
// Allowed remote hosts for fetching network images
allowedNetworkList: ["cdn.example.com", "images.example.com"],
// Custom Cache-Control header
cacheControl: "public, max-age=86400, stale-while-revalidate=604800",
// Enable/disable ETag generation
etag: true,
// Image dimension bounds
minWidth: 50,
maxWidth: 4000,
minHeight: 50,
maxHeight: 4000,
// Default JPEG/WebP/AVIF quality
defaultQuality: 80,
// Network fetch timeout (ms)
requestTimeoutMs: 5000,
// Optional timeout (ms) applied when awaiting an async `idHandler`.
// Defaults to `requestTimeoutMs` when unset.
idHandlerTimeoutMs: 2000,
// Maximum image size in bytes โ applies to both network fetches AND
// local filesystem reads (oversized local files fall back the same way
// oversized remote responses do).
maxDownloadBytes: 5_000_000,
// Max HTTP redirects to follow during network fetches. Each hop is
// re-validated against `allowedNetworkList`, the http/https protocol
// guard, and the public-IP DNS check (SSRF protection). Range 0..10.
maxRedirects: 3,
// Max input pixels enforced by Sharp. Defaults to 256 megapixels.
// Protects against decompression bombs (small encoded payload that
// decompresses to billions of pixels).
maxInputPixels: 16_000 * 16_000,
// Reject SVG inputs by default. SVG decoding has historically been a
// vector for XML-bomb / billion-laughs / nested `<use>` exploits.
allowSvgInput: false,
// Optional observability hook fired at every catch site. The framework
// always continues to serve a fallback image โ the hook is purely for
// logs / metrics / APM. Throws from the hook are swallowed.
onError: (err, ctx) => {
// ctx: { phase: "sharp" | "fetch" | "fs" | "idHandler"
// | "getUserFolder" | "schema" | "validation" | string,
// src?: string, userId?: string }
console.warn("pixel-serve error", ctx.phase, err);
},
// Optional observability hook fired after a successful response (200)
// and after the 304 cached short-circuit. Use this to ship per-request
// latency metrics or count cache-hit ratios. Throws are swallowed.
onComplete: (ctx) => {
// ctx: { src?: string, userId?: string, format: ImageFormat,
// outputBytes: number, cached: boolean, durationMs: number }
console.log("pixel-serve completed", ctx.format, ctx.durationMs, "ms",
ctx.cached ? "(304 cached)" : `${ctx.outputBytes} bytes`);
},
});
app.get("/api/v1/pixel/serve", serveImage);
app.listen(3000);
```
## Configuration Options
| Option | Type | Default | Description |
| -------------------- | ----------------------------------------- | ------------------ | ----------------------------------------------------------------------- |
| `baseDir` | `string` | **required** | Base directory for local images |
| `idHandler` | `(id: string) => string \| Promise<string>` | `id => id` | Transform user IDs before lookup. May be sync or async. Throws, rejections, non-string returns, and slow promises that exceed `idHandlerTimeoutMs` are caught โ the request falls back to the raw `userId` instead of failing. |
| `getUserFolder` | `(req, id?) => string \| Promise<string>` | `undefined` | Resolve private folder path when `folder=private` |
| `getUserFolderRootDir` | `string` | `undefined` | Optional containment root for `getUserFolder` results. When set, the framework validates that the returned path resolves (via `fs.realpath` + `path.relative`) inside this directory; escapes (`../etc`, symlink redirection, etc.) trigger `onError` with `phase: "getUserFolder"` and the request falls back to the public `baseDir`. When unset, the caller must sanitize `userId` themselves inside `getUserFolder`. |
| `websiteURL` | `string` | `undefined` | If set, internal URLs pointing to this host are treated as local assets |
| `apiRegex` | `RegExp` | `/^\/api\/v1\//` | Regex stripped from internal URL pathnames before local lookup. **Must be a safe (non-ReDoS) regex** โ see [API Prefix and ReDoS Safety](#api-prefix-and-redos-safety) below. Ignored when `apiPrefix` is set. |
| `apiPrefix` | `string` | `undefined` | Optional literal-string prefix stripped from internal URL pathnames. When set, **takes precedence over `apiRegex`** and uses a plain `startsWith` + `slice`, sidestepping the ReDoS risk of a user-supplied regex. Recommended whenever you only need to strip a literal path prefix. |
| `allowedNetworkList` | `string[]` | `[]` | Allowed remote hosts. Others immediately fall back. **Entries are trimmed and lowercased at schema-parse time**, so `["CDN.Example.com"]` matches a request URL whose hostname the WHATWG URL parser has lowercased to `cdn.example.com`. |
| `cacheControl` | `string` | `undefined` | Cache-Control header value |
| `etag` | `boolean` | `true` | Emit ETag and honor If-None-Match |
| `minWidth` | `number` | `50` | Minimum accepted width |
| `maxWidth` | `number` | `4000` | Maximum accepted width |
| `minHeight` | `number` | `50` | Minimum accepted height |
| `maxHeight` | `number` | `4000` | Maximum accepted height |
| `defaultQuality` | `number` | `80` | Default JPEG/WebP/AVIF quality |
| `requestTimeoutMs` | `number` | `5000` | Network fetch timeout |
| `idHandlerTimeoutMs` | `number` | `requestTimeoutMs` | Maximum time (ms) to await an async `idHandler` before bailing to the raw `userId`. |
| `maxDownloadBytes` | `number` | `5_000_000` | Maximum image size in bytes. Applies to **both network fetches and local filesystem reads** โ local files are stat-checked before `fs.readFile` is invoked, so an oversized image on disk falls back the same way an oversized remote response does. |
| `maxRedirects` | `number` | `3` | Maximum HTTP redirects followed during network fetches. Each hop is re-validated against the allowlist, the http/https protocol guard, and the public-IP DNS check. Range `0..10`. |
| `maxInputPixels` | `number` | `16_000 * 16_000` | Maximum input image pixel count enforced by Sharp. Protects against decompression bombs (small encoded buffer that decodes to billions of pixels). Defaults to 256 megapixels. |
| `allowSvgInput` | `boolean` | `false` | Allow SVG inputs through to Sharp/libvips. Defaults to `false` โ SVGs can contain malicious payloads (XML bombs, billion-laughs, nested `<use>`) parsed by libvips/librsvg. Detected via magic-byte sniffing and rejected unless this flag is explicitly enabled. |
| `onError` | `(err, { phase, src?, userId? }) => void` | `undefined` | Optional observability hook. Invoked at every catch site so you can ship structured logs / metrics / APM events. Phases include `"sharp"`, `"fetch"`, `"fs"`, `"idHandler"`, `"getUserFolder"`, `"schema"`, and `"validation"`. The hook is best-effort: throws from the hook are suppressed and never break the response. |
| `onComplete` | `(ctx: { src?, userId?, format, outputBytes, cached, durationMs }) => void` | `undefined` | Optional observability hook invoked after the response has been flushed on the happy path (200 with image bytes) and on the 304 cached short-circuit. `format` is the output format actually used; `outputBytes` is the response body size in bytes (0 for 304s); `cached` is `true` when the response was served as 304 Not Modified; `durationMs` is the monotonic end-to-end latency captured via `process.hrtime.bigint()`. Use this hook to ship per-request latency metrics, count cache-hit ratios, or feed structured logs into your APM. The hook is best-effort: throws from the hook are suppressed and never escape the middleware. |
## Query Parameters
| Parameter | Type | Default | Description |
| --------- | ----------------------- | ----------- | ------------------------------------------------------------------- |
| `src` | `string` | _required_ | Path or URL to the image source |
| `format` | `ImageFormat` | `jpeg` | Output format (`jpeg`, `png`, `webp`, `gif`, `tiff`, `avif`). SVG is not supported as an output format. |
| `width` | `number` | `undefined` | Desired output width (px) |
| `height` | `number` | `undefined` | Desired output height (px) |
| `quality` | `number` | `80` | Image quality (1-100) |
| `folder` | `'public' \| 'private'` | `public` | Image folder type |
| `userId` | `string` | `undefined` | User ID for private folder access |
| `type` | `'normal' \| 'avatar'` | `normal` | Image type (affects fallback image) |
## Example Requests
### Local Image with Resize
```bash
GET /api/v1/pixel/serve?src=uploads/photo.jpg&width=800&height=600&format=webp
```
### Network Image
```bash
GET /api/v1/pixel/serve?src=https://cdn.example.com/image.jpg&format=avif&quality=90
```
### Private User Image
```bash
GET /api/v1/pixel/serve?src=avatar.jpg&folder=private&userId=12345&type=avatar
```
## Integration with Pixel Serve Client
This package is designed to work seamlessly with [`pixel-serve-client`](https://www.npmjs.com/package/pixel-serve-client), a React component that automatically generates the correct query parameters.
```tsx
// Client-side (React)
import Pixel from "pixel-serve-client";
<Pixel
src="/uploads/photo.jpg"
width={800}
height={600}
backendUrl="/api/v1/pixel/serve"
/>;
```
## Security Features
### Path Traversal Protection
All local paths are validated to prevent directory traversal attacks:
- Rejects paths with `..`
- Rejects absolute paths
- Validates resolved paths stay within `baseDir`
- Rejects null bytes and control characters
### Network Image Security
- Only fetches from explicitly allowed domains (`allowedNetworkList`). Allowlist entries are normalised (trimmed + lowercased) at schema-parse time so the case-insensitive matching contract is enforced regardless of how the option was supplied (env file, JSON config, etc.).
- Validates MIME type of responses
- Configurable timeout and size limits
- Rejects non-HTTP/HTTPS protocols
### SSRF Redirect Protection
- HTTP redirects are **never auto-followed**. Axios is invoked with `maxRedirects: 0` and the middleware runs a manual redirect loop (default budget: 3 hops, capped at 10 via `maxRedirects`).
- **Every hop is re-validated**: protocol must be `http`/`https`, host must be in `allowedNetworkList`, and the destination hostname must resolve to a public IP.
- **Private/loopback/link-local IPs are blocked** even when the host is allowlisted โ this stops redirects to RFC1918 ranges, `127.0.0.0/8` loopback, `169.254.0.0/16` link-local (including the AWS IMDS endpoint `169.254.169.254`), IPv6 loopback (`::1`), unique-local (`fc00::/7`), and IPv4-mapped private IPv6.
- **DNS rebinding mitigation (pinned `lookup`).** Every hop resolves the destination hostname **once** via `dns.lookup`, validates the resolved address is public, then passes axios a per-request `httpAgent`/`httpsAgent` whose `lookup` function is pinned to that exact `{ address, family }` pair. The TCP socket is therefore guaranteed to connect to the IP the framework validated, rather than whatever the kernel resolver returns microseconds later. This closes the classic DNS-rebinding TOCTOU window where an attacker-controlled authoritative server answers the validation lookup with a public IP and the subsequent connect-time lookup with `127.0.0.1` / `169.254.169.254`. Each redirect hop re-resolves and re-pins so chained rebinding attempts are also defeated.
### Decompression-Bomb Protection
- Sharp is constructed with `{ failOn: "warning", limitInputPixels: maxInputPixels, sequentialRead: true, unlimited: false }`, so malformed or oversized inputs fail fast.
- Before the full decode, the pipeline performs a `metadata()` peek and rejects any image whose `width * height` exceeds `maxInputPixels` (default 256MP). This blocks small encoded payloads that would decompress to billions of pixels and OOM the worker.
### SVG Input Rejection
- SVG inputs are rejected by default. The middleware uses a magic-byte sniffer that detects `<svg`, `<?xml ... <svg`, UTF-8 BOM-prefixed SVG, and comment-prefixed SVG, then bails to the fallback image before reaching libvips/librsvg.
- This guards against XML bombs, billion-laughs attacks, and nested `<use>` exploits historically parsed during SVG decoding.
- Set `allowSvgInput: true` to opt in โ only do so when the source pipeline is fully trusted.
### API Prefix and ReDoS Safety
Internal URLs (those matching `websiteURL`) are stripped of an API path prefix before being resolved against `baseDir`. Two options control this:
- **`apiPrefix` (recommended).** A literal string prefix. The middleware does a plain `pathname.startsWith(apiPrefix)` check followed by `pathname.slice(apiPrefix.length)`. No regex evaluation, so it cannot be made vulnerable.
```ts
const serveImage = registerServe({
baseDir: "/public/images",
websiteURL: "example.com",
apiPrefix: "/api/v1/", // strips "/api/v1/photo.jpg" โ "photo.jpg"
});
```
- **`apiRegex` (legacy / advanced).** A regex applied via `String.prototype.replace`. Only use this when you need wildcards or alternations. **`apiRegex` accepts an arbitrary user-supplied `RegExp` and runs it against client-controlled `url.pathname` values, so a vulnerable pattern (`/^(a+)+\/$/`, nested quantifiers, ambiguous alternation) opens the deployment to catastrophic-backtracking denial-of-service (ReDoS).** The default `/^\/api\/v1\//` is anchored and literal and is not vulnerable; audit any custom pattern with a tool like [safe-regex](https://www.npmjs.com/package/safe-regex) before shipping.
```ts
const serveImage = registerServe({
baseDir: "/public/images",
websiteURL: "example.com",
apiRegex: /^\/api\/v[12]\//, // safe: anchored, no nested quantifiers
});
```
**Precedence.** When both options are supplied, `apiPrefix` wins โ the regex is not evaluated at all, so a misconfigured `apiRegex` cannot reach the request path. Unset `apiPrefix` to opt back into regex behavior.
### Private Folder Access
Use `getUserFolder` to implement your own authentication/authorization logic:
```typescript
const serveImage = registerServe({
baseDir: "/public/images",
// Optional but recommended: when set, the framework verifies the path
// returned by `getUserFolder` resolves inside this directory and falls
// back to `baseDir` if it escapes (e.g., a malicious `userId` that joins
// to `../etc` or a symlink that points outside the tree).
getUserFolderRootDir: "/private/users",
getUserFolder: async (req, userId) => {
const user = await verifyToken(req.headers.authorization);
if (!user || user.id !== userId) {
return ""; // Empty/falsy result keeps `baseDir`
}
return `/private/users/${userId}`;
},
});
```
> **Without `getUserFolderRootDir`, the framework cannot enforce containment.**
> You are responsible for sanitizing `userId` inside your own callback (forbid
> `..`, slashes, backslashes, and control characters). Setting
> `getUserFolderRootDir` adds a defense-in-depth realpath check that runs
> after your callback returns so a buggy implementation cannot expand the
> filesystem surface area beyond an opt-in root.
## Caching
### Deterministic ETag (pre-Sharp short-circuit)
When `etag: true` (the default), the middleware builds a SHA-1 ETag from a deterministic key combining `src`, `width`, `height`, `format`, `quality`, `type`, `folder`, the post-`idHandler` `userId`, and a source identifier (`mtimeMs:size` for local files, the resolved URL for remote sources). The key is computed **before** any Sharp work, so an `If-None-Match` request that hits a known ETag returns `304 Not Modified` immediately โ no decode, no resize, no re-encode.
When a deterministic key cannot be derived (e.g., the source file is missing and the pipeline falls back to a placeholder image), the framework computes a SHA-1 over the processed buffer instead, preserving the historical ETag contract for fallback responses.
### Content-Disposition and `Vary` Header
Responses include an RFC 6266 / RFC 5987 `Content-Disposition` header with **both** a quoted ASCII `filename=` parameter and a percent-encoded `filename*=UTF-8''<encoded>` parameter, so unicode filenames (Arabic, CJK, etc.) round-trip cleanly through clients and proxies. Query strings and fragments are stripped before the filename is derived, only-punctuation basenames fall back to `image`, and very long names are truncated so the response header stays bounded.
Every successful response also carries `Vary: Accept-Encoding` for downstream cache correctness.
## Observability
Two optional best-effort hooks let you wire the middleware into your logging, metrics, and APM stack. Both run synchronously after the response has been handled, and both swallow throws โ a buggy logger can never break the response.
### `onError` โ failure pings
Fired at every catch site in the request pipeline. The middleware always continues to serve a fallback image; the hook is purely for logs / metrics / APM:
```typescript
const serveImage = registerServe({
baseDir: "/public/images",
onError: (err, ctx) => {
// ctx: { phase: "sharp" | "fetch" | "fs" | "idHandler"
// | "getUserFolder" | "schema" | "validation" | string,
// src?: string, userId?: string }
logger.warn({ err, ...ctx }, "pixel-serve error");
metrics.increment(`pixel_serve.errors.${ctx.phase}`);
},
});
```
### `onComplete` โ success + cache-hit pings
Fired after the response has been flushed on the happy path (200 with image bytes) **and** on the 304 cached short-circuit. The `cached` flag distinguishes the two paths, so a single hook can drive both latency histograms and cache-hit ratios:
```typescript
const serveImage = registerServe({
baseDir: "/public/images",
onComplete: (ctx) => {
// ctx: { src?: string, userId?: string, format: ImageFormat,
// outputBytes: number, cached: boolean, durationMs: number }
metrics.histogram("pixel_serve.latency_ms", ctx.durationMs, {
format: ctx.format,
cached: String(ctx.cached),
});
metrics.increment(
ctx.cached ? "pixel_serve.cache_hit" : "pixel_serve.cache_miss"
);
if (!ctx.cached) {
metrics.histogram("pixel_serve.output_bytes", ctx.outputBytes, {
format: ctx.format,
});
}
},
});
```
`durationMs` is captured via `process.hrtime.bigint()` for monotonic precision, so it is safe to feed directly into a latency histogram. `outputBytes` is the size of the response body in bytes (`0` for a 304, the encoded image size for a 200). `format` is the output format actually produced by the response โ useful for slicing metrics by AVIF / WebP / JPEG.
Throws from either hook are swallowed.
## Error Handling
Every catch site in the pipeline (Sharp, network fetch, filesystem read, `idHandler`, `getUserFolder`, schema, validation) serves a fallback image without exposing stack traces or system paths, then notifies `onError` if configured. The middleware itself never invokes Express's `next(error)` on the happy path.
There is one exception: if the response was already partially flushed (`res.headersSent === true`) at the moment the outer catch fires, the middleware cannot recover into a fresh fallback without tripping `ERR_HTTP_HEADERS_SENT`. In that case it surfaces an `Error("response already flushed")` via `next(err)` and fires `onError` with `phase: "fs"` so the connection is torn down cleanly. The current happy path only flushes via `res.send` at the very end of the pipeline, so this guard is defence-in-depth for future streaming refactors that may write headers earlier.
## Performance
### Per-Request Memory Footprint
The current pipeline materializes intermediate buffers rather than streaming Sharp's output to the response. As a rough rule of thumb the in-flight memory cost of a single request is:
```text
~= source_buffer_size (โค maxDownloadBytes; default 5 MB)
+ processed_buffer_size (decoded โ resized โ re-encoded output)
+ transient_etag_buffer (SHA-1 over the processed buffer, fallback path only)
```
For most photo workloads the processed buffer is smaller than the source (re-encoding shrinks the payload), but pathological inputs (e.g., a 4 MB AVIF that decodes to a 50 MP raster which then re-encodes to a larger PNG) can push the high-water mark above twice the source size. Sharp decoding itself also requires a libvips work buffer proportional to `width ร height ร channels` outside the Node.js heap, which is bounded by `maxInputPixels` (default 256 MP).
Practical guidance:
- **Set `maxDownloadBytes`** tightly for your traffic profile โ every running request can hold up to this many bytes for the source alone.
- **Set `maxInputPixels`** to the largest output you actually need. Decompression bombs are blocked, but a generous limit (e.g., 256 MP) still allocates libvips work memory proportional to the decoded raster.
- **Cap concurrency at the reverse proxy or process manager.** Sharp processing is **CPU-intensive** โ the per-CPU concurrency is what bounds total memory under load, not Node's default request concurrency.
### CPU and the Cacheability Win
Sharp's decode โ rotate โ resize โ re-encode pipeline is CPU-bound and dominates request latency for cold cache hits. To minimize cost:
- **Use `cacheControl` aggressively.** Setting `Cache-Control: public, max-age=โฆ, stale-while-revalidate=โฆ` lets browsers and intermediate caches serve the image without ever round-tripping back to the middleware.
- **Put a CDN in front.** Cloudflare, CloudFront, Fastly, etc. honor `Cache-Control` and `ETag` headers and can shield the origin from repeated processing entirely.
- **Lean on the deterministic ETag short-circuit.** When `etag: true` (the default), the middleware computes a SHA-1 ETag from a stable cache key (`src` + `width` + `height` + `format` + `quality` + `type` + `folder` + post-`idHandler` `userId` + source identifier) **before** any Sharp work. An `If-None-Match` request that matches a known ETag returns `304 Not Modified` immediately โ **no decode, no resize, no re-encode, no allocation of the processed buffer**. This is the cheapest possible response the middleware can produce and is the primary reason origin CPU stays bounded under repeated traffic for the same image variant.
> Streaming Sharp's output directly to `res` (instead of materializing the processed buffer) would further reduce the per-request high-water mark, but it is **not** currently supported โ emitting a deterministic ETag requires either the buffer hash or the deterministic key, and the framework prefers the latter precisely because it preserves cacheability without forcing the full pipeline to run.
## Fallback Images
The package includes built-in fallback images for:
- **Normal images**: Displayed when an image cannot be loaded
- **Avatars**: Displayed when an avatar image cannot be loaded
These are automatically served when:
- The requested image doesn't exist
- Path validation fails
- Network fetch fails or returns invalid data
- Image processing fails
## Exports
```typescript
// Main middleware factory
import { registerServe } from "pixel-serve-server";
// Types
import type {
PixelServeOptions,
UserData,
ImageFormat,
ImageType,
PixelServeOnError,
PixelServeErrorContext,
PixelServeErrorPhase,
PixelServeOnComplete,
PixelServeCompletionContext,
} from "pixel-serve-server";
// Zod schemas for validation
import { optionsSchema, userDataSchema } from "pixel-serve-server";
// Utility function
import { isValidPath } from "pixel-serve-server";
```
### Helpers
Eleven additional helper functions are exported for downstream tooling โ precomputing ETags for offline cache priming, sharing the SSRF/containment primitives with custom middleware, sniffing SVG inputs before they reach Sharp, and so on. They are part of the supported public API and have JSDoc + test coverage.
**Security helpers (SSRF / containment)**
- `isPrivateIp(address: string): boolean` โ Returns `true` for any address in an IANA-reserved range that should never be reachable over the public internet (RFC 1918, loopback, link-local, unique-local, multicast, `0.0.0.0`, IPv4-mapped private IPv6, the AWS IMDS endpoint).
- `isPublicHost(hostname: string): Promise<boolean>` โ Resolves a hostname via `dns.lookup` and returns `true` only when the resolved address passes `isPrivateIp` rejection. Use this to gate any outbound request you build outside the middleware.
- `resolvePinnedAddress(hostname: string): Promise<{ address: string, family: 4 | 6 }>` โ Resolves a hostname once and returns the validated `{ address, family }` pair so a subsequent socket connection can be pinned to the exact IP the validator approved (DNS-rebinding mitigation).
- `buildPinnedAgents(pinned: { address: string, family: 4 | 6 }): { httpAgent, httpsAgent }` โ Builds `http.Agent` and `https.Agent` instances whose `lookup` function is pinned to the supplied `{ address, family }`. Drop them into axios / fetch to guarantee the TCP socket connects to the validated IP.
- `isInsideRoot(rootDir: string, candidatePath: string): Promise<boolean>` โ Realpath-resolves both inputs and returns `true` only when `candidatePath` is a descendant of `rootDir`. Useful for custom containment checks around private-folder logic.
- `resolveRootDir(rootDir: string): Promise<string>` โ Realpath-resolves a configured root directory once; returns the canonical absolute path you should compare against in subsequent containment checks.
- `looksLikeSvg(buffer: Buffer): boolean` โ Magic-byte sniffer for SVG inputs (handles `<svg`, `<?xml โฆ <svg`, UTF-8 BOM-prefixed, and comment-prefixed payloads). Returns `true` when libvips/librsvg would attempt to decode the buffer as SVG.
**ETag / source-identifier helpers**
- `buildSourceIdentifier(absolutePath?: string, url?: string): Promise<string | null>` โ Builds the deterministic source fingerprint used inside the ETag key: `mtimeMs:size` for a local file (`fs.stat`) or the resolved URL string for a remote source. Returns `null` when no stable identifier can be derived.
- `buildDeterministicEtag(parts: { src, width, height, format, quality, type, folder, userId?, sourceIdentifier }): string` โ Computes the SHA-1 ETag used by the middleware **before** any Sharp work runs. Same inputs produce the same ETag, so you can pre-warm a CDN or short-circuit an `If-None-Match` request without invoking the full pipeline.
**Path / API helpers**
- `stripApiPrefix(pathname: string, options: { apiPrefix?: string, apiRegex?: RegExp }): string` โ Strips the configured API prefix from a URL pathname using the same precedence rules as the middleware (`apiPrefix` literal `startsWith` wins over `apiRegex`).
- `buildFilename(src: string, format: ImageFormat): { asciiFilename, encodedFilename }` โ Builds the dual `filename=` / `filename*=UTF-8''โฆ` pair used in `Content-Disposition`. Handles RFC 5987 percent-encoding, truncation that respects `%XX` boundaries, and the empty/punctuation-only basename fallback to `image`.
```typescript
import {
isPrivateIp,
isPublicHost,
resolvePinnedAddress,
buildPinnedAgents,
isInsideRoot,
resolveRootDir,
looksLikeSvg,
buildSourceIdentifier,
buildDeterministicEtag,
stripApiPrefix,
buildFilename,
} from "pixel-serve-server";
```
## Module Formats
```typescript
// ESM
import { registerServe } from "pixel-serve-server";
// CommonJS
const { registerServe } = require("pixel-serve-server");
```
## Versioning and Migration
`pixel-serve-server` follows [semantic versioning](https://semver.org). The
current major line is **2.x**; see [`MIGRATION.md`](./MIGRATION.md) for the
1.x โ 2.x upgrade guide (SVG output removal, the `userDataSchema.src`
relaxation, new security-hardening defaults, etc.). Patches and minor
releases inside the 2.x line are backward-compatible โ see
[`CHANGELOG.md`](./CHANGELOG.md) for the full history.
## Requirements
- Node.js >= 20
- Express 5.x (included as a dependency)
## Dependencies
- **Sharp**: High-performance image processing
- **Axios**: HTTP client for fetching network images
- **Zod**: Runtime validation for options and query params
## License
MIT
## Contributing
Issues and pull requests are welcome at [GitHub](https://github.com/Hiprax/pixel-serve-server).
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the local development workflow,
coverage expectations, and PR guidelines.
## Security
See [`SECURITY.md`](./SECURITY.md) for the disclosure policy, supported
versions, and the in-scope / out-of-scope vulnerability classes. Please **do
not** open public GitHub issues for security reports.