pixel-serve-server
v2.8.7
Published
A robust Node.js utility for handling and processing images. This package provides features like resizing, format conversion and etc.
Maintainers
Readme
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.
Features
- 🖼️ Dynamic resizing & formatting:
jpeg,png,webp,gif,tiff,avifwith 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
getUserFolderfor 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+).
npm install pixel-serve-serverQuick Start
Basic Setup (Express)
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"inpackage.json, or notypefield). In ECMAScript Modules ("type": "module"or.mjsfiles)__dirnamedoes not exist and the example will throwReferenceError: __dirname is not defined. Derive it fromimport.meta.urlinstead:// 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
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 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
GET /api/v1/pixel/serve?src=uploads/photo.jpg&width=800&height=600&format=webpNetwork Image
GET /api/v1/pixel/serve?src=https://cdn.example.com/image.jpg&format=avif&quality=90Private User Image
GET /api/v1/pixel/serve?src=avatar.jpg&folder=private&userId=12345&type=avatarIntegration with Pixel Serve Client
This package is designed to work seamlessly with pixel-serve-client, a React component that automatically generates the correct query parameters.
// 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: 0and the middleware runs a manual redirect loop (default budget: 3 hops, capped at 10 viamaxRedirects). - Every hop is re-validated: protocol must be
http/https, host must be inallowedNetworkList, 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/8loopback,169.254.0.0/16link-local (including the AWS IMDS endpoint169.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 viadns.lookup, validates the resolved address is public, then passes axios a per-requesthttpAgent/httpsAgentwhoselookupfunction 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 with127.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 whosewidth * heightexceedsmaxInputPixels(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: trueto 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 plainpathname.startsWith(apiPrefix)check followed bypathname.slice(apiPrefix.length). No regex evaluation, so it cannot be made vulnerable.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 viaString.prototype.replace. Only use this when you need wildcards or alternations.apiRegexaccepts an arbitrary user-suppliedRegExpand runs it against client-controlledurl.pathnamevalues, 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 before shipping.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:
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 sanitizinguserIdinside your own callback (forbid.., slashes, backslashes, and control characters). SettinggetUserFolderRootDiradds 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:
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:
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:
~= 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
maxDownloadBytestightly for your traffic profile — every running request can hold up to this many bytes for the source alone. - Set
maxInputPixelsto 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
cacheControlaggressively. SettingCache-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-ControlandETagheaders 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-idHandleruserId+ source identifier) before any Sharp work. AnIf-None-Matchrequest that matches a known ETag returns304 Not Modifiedimmediately — 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
// 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— Returnstruefor 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 viadns.lookupand returnstrueonly when the resolved address passesisPrivateIprejection. 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 }— Buildshttp.Agentandhttps.Agentinstances whoselookupfunction 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 returnstrueonly whencandidatePathis a descendant ofrootDir. 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). Returnstruewhen 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:sizefor a local file (fs.stat) or the resolved URL string for a remote source. Returnsnullwhen 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 anIf-None-Matchrequest 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 (apiPrefixliteralstartsWithwins overapiRegex).buildFilename(src: string, format: ImageFormat): { asciiFilename, encodedFilename }— Builds the dualfilename=/filename*=UTF-8''…pair used inContent-Disposition. Handles RFC 5987 percent-encoding, truncation that respects%XXboundaries, and the empty/punctuation-only basename fallback toimage.
import {
isPrivateIp,
isPublicHost,
resolvePinnedAddress,
buildPinnedAgents,
isInsideRoot,
resolveRootDir,
looksLikeSvg,
buildSourceIdentifier,
buildDeterministicEtag,
stripApiPrefix,
buildFilename,
} from "pixel-serve-server";Module Formats
// ESM
import { registerServe } from "pixel-serve-server";
// CommonJS
const { registerServe } = require("pixel-serve-server");Versioning and Migration
pixel-serve-server follows semantic versioning. The
current major line is 2.x; see 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 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.
See CONTRIBUTING.md for the local development workflow,
coverage expectations, and PR guidelines.
Security
See 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.
