UNPKG

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
# 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. [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![npm version](https://img.shields.io/npm/v/pixel-serve-server)](https://www.npmjs.com/package/pixel-serve-server) [![CI](https://github.com/Hiprax/pixel-serve-server/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Hiprax/pixel-serve-server/actions/workflows/ci.yml) [![CodeQL](https://github.com/Hiprax/pixel-serve-server/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/Hiprax/pixel-serve-server/actions/workflows/codeql.yml) [![codecov](https://codecov.io/gh/Hiprax/pixel-serve-server/branch/main/graph/badge.svg)](https://codecov.io/gh/Hiprax/pixel-serve-server) [![npm provenance](https://img.shields.io/badge/npm%20provenance-built%20%26%20signed-success?logo=npm&logoColor=white)](https://www.npmjs.com/package/pixel-serve-server) [![TypeScript](https://img.shields.io/badge/TypeScript-6.0.3-blue.svg)](https://www.typescriptlang.org/) [![Node.js](https://img.shields.io/badge/Node.js-20+-blue.svg)](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.