Skip to content

unjs/ipx

Repository files navigation

🖼️ IPX

npm version npm downloads

High performance, secure and easy-to-use image optimizer powered by sharp and svgo.

Point IPX at a directory or a list of allowed domains, and every image is available in any size, format and quality straight from its URL:

/w_512,f_webp/photos/buffalo.png

Note

This is the active development branch for IPX v4. Check out v3 for v3 docs, and release notes (alpha, beta) if you are upgrading.

Quick Start

Start a server for the images in the current directory with the ipx command:

npx ipx serve --dir ./

Using bun:

bunx ipx serve --dir ./

Then open the printed URL and add modifiers to any image path, for example http://localhost:3000/w_200/buffalo.png.

To embed IPX in your own app instead, see the Programmatic API.

Image URLs

A request URL is a list of modifiers, followed by the id of the source image:

/<modifiers>/<id>

Multiple modifiers are separated by , and their arguments by _. Use _ alone when no modifier is needed.

URL Result
/_/static/buffalo.png The original image.
/w_200/static/buffalo.png Width set to 200, original format (png) kept.
/f_webp/static/buffalo.png Format changed to webp, everything else kept as in the source.
/f_auto/static/buffalo.png Best format for the client (avif/webp/jpeg), negotiated from the browser's accept header.
/s_200x200,fit_contain,f_webp/static/buffalo.png Resized to fit inside 200x200px on a background canvas and converted to webp.

The URL style is configurable, see Custom URL Style.

Modifiers

Modifier arguments are separated with _ and validated before they reach sharp. Invalid input is rejected with a 400 IPX_INVALID_MODIFIER_ARG (or 400 IPX_MISSING_MODIFIER_ARG for a required argument) instead of failing the request. Some arguments can only be validated by libvips once it runs, which happens after the whole pipeline is set up; those surface as a 400 IPX_INVALID_MODIFIER. Trailing arguments may be omitted to keep the sharp default, except where noted.

Colours (background, tint) accept any colour sharp understands: hex (f00, ff0000, ff000080) with an optional leading # — which cannot be used inside a URL path, so it may be dropped — or a CSS colour name (red). Boolean arguments accept true / false as well as the shorter 1 / 0.

/w_160/scene.jpg
width / w
/w_160/scene.jpg
Resize to a width in pixels, a positive integer. The height follows the aspect ratio. (docs)
/h_120/scene.jpg
height / h
/h_120/scene.jpg
Resize to a height in pixels, a positive integer. The width follows the aspect ratio. (docs)
/s_200x200/scene.jpg
resize / s
/s_200x200/scene.jpg
Resize to {width}x{height}, both positive integers. A single value (s_200) is a square. (docs)
/s_80x80,kernel_nearest/photo.jpg
kernel
/s_80x80,kernel_nearest/photo.jpg
Sets kernel option for resize. One of nearest, linear, cubic, mitchell, lanczos2, lanczos3 (default), mks2013 or mks2021. (docs)
/s_300x150,fit_contain,b_00ff00/scene.jpg
fit
/s_300x150,fit_contain,b_00ff00/scene.jpg
Sets fit option for resize. One of contain, cover (default), fill, inside or outside. (docs)
/s_150x300,pos_top/scene.jpg
position / pos
/s_150x300,pos_top/scene.jpg
Sets position option for resize. A position (top, right top, ..., left top), gravity (north, northeast, ..., center) or strategy (entropy, attention), also accepted as their numeric constants. Since _ separates arguments, multi-word values use - (pos_right-top). (docs)
/extend_20_40_20_40_mirror/scene.jpg
extend
/extend_20_40_20_40_mirror/scene.jpg
Pad or extrude the edges, as extend_{top}_{right}_{bottom}_{left} with an optional {extendWith}. Edges are integers between 0 and 10000. extendWith is background (default), copy, repeat or mirror; background takes its colour from the background / b modifier. (docs)
/rotate_45,b_00ff00/scene.jpg
background / b
/rotate_45,b_00ff00/scene.jpg
Background colour, a hex (f00, ff0000) or named (red) colour. Used by extend, rotate, flatten, opacity and resize (with fit_contain).
/extract_150_10_160_120/scene.jpg
extract / crop
/extract_150_10_160_120/scene.jpg
Crop a region, as extract_{left}_{top}_{width}_{height}. All four arguments are required; width and height must be positive. (docs)
/f_webp/photo.jpg
format / f
/f_webp/photo.jpg
Output format. One of jpg, jpeg, png, webp, avif, gif, heif, tiff or auto (experimental, only with middleware). (docs)
/q_10/photo.jpg
quality / q
/q_10/photo.jpg
Encoding quality, an integer between 1 and 100. A lower value means a smaller file.
/rotate_45/scene.jpg
rotate
/rotate_45/scene.jpg
Angle in degrees, between -3600 and 3600. Angles that are not a multiple of 90 fill the corners with the background / b colour. Without an angle (rotate) the image is auto-oriented from its EXIF tag. (docs)
/enlarge,s_400x400/photo.jpg
enlarge
/enlarge,s_400x400/photo.jpg
Allow the image to be upscaled. Without it, the output is never larger than the source in any dimension, while preserving the requested aspect ratio.
/flip/scene.jpg
flip
/flip/scene.jpg
Mirror the image vertically, about the horizontal axis. Combine with flop to turn it upside down. (docs)
/flop/scene.jpg
flop
/flop/scene.jpg
Mirror the image horizontally, about the vertical axis. Combine with flip to turn it upside down. (docs)
/sharpen_5/photo.jpg
sharpen
/sharpen_5/photo.jpg
Sharpen the image, as {sigma}_{flat}_{jagged}_{x1}_{y2}_{y3}. sigma is a number between 0.000001 and 10, the rest between 0 and 1000000. Without arguments (sharpen) a mild sharpen is applied. (docs)
/median_10/photo.jpg
median
/median_10/photo.jpg
Apply a median filter, removing noise while keeping edges. Square mask size, an integer between 1 and 1000 (defaults to 3). (docs)
/blur_5/edges.jpg
blur
/blur_5/edges.jpg
Gaussian blur, as {sigma}_{precision}_{minAmplitude}. sigma is a number between 0.3 and 1000, precision one of integer (default), float or approximate, minAmplitude between 0.001 and 1. Without arguments (blur) a mild blur is applied. (docs)
/dilate_4/graphic.png
dilate
/dilate_4/graphic.png
Expand foreground objects. Width in pixels, an integer between 1 and 100 (defaults to 1), capped below the sharp maximum since the cost grows with the width. (docs)
/erode_4/graphic.png
erode
/erode_4/graphic.png
Shrink foreground objects. Width in pixels, an integer between 1 and 100 (defaults to 1), capped below the sharp maximum since the cost grows with the width. (docs)
/clahe_20_20_5/edges.jpg
clahe
/clahe_20_20_5/edges.jpg
Contrast limiting adaptive histogram equalization, as {width}_{height}_{maxSlope}. width is required and height defaults to it (a square window); both are integers between 1 and 100, clamped to the source dimensions. maxSlope is an integer between 0 and 100 (defaults to 3). (docs)
/flatten,b_00ff00,f_jpeg/alpha.png
flatten
/flatten,b_00ff00,f_jpeg/alpha.png
Remove the alpha channel, if any, and replace transparency with the background / b colour. (docs)
/unflatten,f_webp/graphic.png
unflatten
/unflatten,f_webp/graphic.png
Make every fully white pixel transparent, so the output format needs an alpha channel (png, webp, avif). (docs)
/gamma_3/colour.jpg
gamma
/gamma_3/colour.jpg
Gamma correction, as {gamma}_{gammaOut}, each a number between 1.0 and 3.0 (defaults to 2.2). (docs)
/negate/colour.jpg
negate
/negate/colour.jpg
Produce the negative of the image. Optional {alpha} (true by default) controls whether the alpha channel is negated too, e.g. negate_false. (docs)
/normalize_10_90/edges.jpg
normalize
/normalize_10_90/edges.jpg
Stretch the luminance to the full dynamic range, as {lower}_{upper} percentiles. lower is a number between 0 and 99 (defaults to 1), upper between 1 and 100 (defaults to 99) and greater than lower. (docs)
/threshold_128/colour.jpg
threshold
/threshold_128/colour.jpg
Map every pixel to black or white, as {threshold}_{grayscale}. threshold is an integer between 0 and 255 (defaults to 128). Optional grayscale (true by default) converts to single channel first, e.g. threshold_128_false. (docs)
/linear_1.5_-30/colour.jpg
linear
/linear_1.5_-30/colour.jpg
Levels adjustment applying a * input + b, as {a}_{b}. a is the multiplier (defaults to 1) and b the offset (defaults to 0), both numbers. (docs)
/tint_00ff00/colour.jpg
tint
/tint_00ff00/colour.jpg
Tint the image, keeping its luminance. A hex (f00, ff0000) or named (red) colour. (docs)
/grayscale/colour.jpg
grayscale
/grayscale/colour.jpg
Convert the image to 8-bit greyscale, mapping every pixel to its luminance. (docs)
/modulate_1.5_2_90_10/colour.jpg
modulate
/modulate_1.5_2_90_10/colour.jpg
Transform the image, as {brightness}_{saturation}_{hue}_{lightness}. brightness and saturation are numbers >= 0, hue an integer in degrees. Each is also available on its own (below). (docs)
/brightness_1.5/colour.jpg
brightness
/brightness_1.5/colour.jpg
Brightness multiplier, a number >= 0 (1 leaves the image unchanged). Required. (docs)
/saturation_0.3/colour.jpg
saturation
/saturation_0.3/colour.jpg
Saturation multiplier, a number >= 0 (0 is greyscale). Required. (docs)
/hue_90/colour.jpg
hue
/hue_90/colour.jpg
Hue rotation, an integer in degrees that wraps around at 360. Required. (docs)
/lightness_30/colour.jpg
lightness
/lightness_30/colour.jpg
Lightness addend, a number added to the lightness of every pixel. Required. (docs)
/opacity_0.5,f_webp/alpha.png
opacity
/opacity_0.5,f_webp/alpha.png
Opacity, a number between 0 and 1. Required. The image is made transparent, so the output format needs an alpha channel (png, webp, avif), or set the background / b colour to blend into it instead, e.g. opacity_0.5,b_fff,f_jpeg.
/a,w_160/animated.gif
animated / a
/a,w_160/animated.gif
Process every frame of an animated image instead of the first one. Experimental.
No sample: the sample source has no uniform border to trim.
trim
/trim_30/photo.jpg
Trim edge pixels that are within the threshold of the top-left pixel colour. Threshold is a number >= 0 (defaults to 10). (docs)
No sample: the sample source carries no EXIF Orientation tag.
autoorient
/autoorient/photo.jpg
Rotate and flip the image according to its EXIF Orientation tag, then remove the tag. Also applied by rotate without an angle. (docs)

Programmatic API

Create an IPX instance with createIPX(), then either serve it directly or mount it as a handler in your own app.

Example: Using built-in server

import { serveIPX, createIPX, ipxFSStorage, ipxHttpStorage } from "ipx";

const ipx = createIPX({
  storage: ipxFSStorage({ dir: "./public" }),
  httpStorage: ipxHttpStorage({ domains: ["picsum.photos"] }),
  alias: { "/picsum": "https://picsum.photos" },
});

// http://localhost:3000/w_512/picsum/1000
serveIPX(ipx);

Example: Using with h3

import { H3, serve } from "h3";

import {
  createIPX,
  ipxFSStorage,
  ipxHttpStorage,
  createIPXFetchHandler,
} from "ipx";

const ipx = createIPX({
  storage: ipxFSStorage({ dir: "./public" }),
  httpStorage: ipxHttpStorage({ domains: ["picsum.photos"] }),
  alias: { "/picsum": "https://picsum.photos" },
});

const app = new H3();

app.mount("/ipx", createIPXFetchHandler(ipx));

// http://localhost:3000/ipx/w_512/picsum/1000
serve(app);

Example: Using with express

import Express from "express";

import {
  createIPX,
  ipxFSStorage,
  ipxHttpStorage,
  createIPXNodeHandler,
} from "ipx";

import type { RequestHandler } from "express";

const ipx = createIPX({
  storage: ipxFSStorage({ dir: "./public" }),
  httpStorage: ipxHttpStorage({ domains: ["picsum.photos"] }),
  alias: { "/picsum": "https://picsum.photos" },
});

const app = Express();

app.use("/ipx", createIPXNodeHandler(ipx) as RequestHandler);

// http://localhost:3000/ipx/w_512/picsum/1000
app.listen(3000, () => {
  console.log("Server is running on http://localhost:3000");
});

Config

Every option can also be set universally with an IPX_* environment variable, which is how the CLI is configured. Explicit options win over the environment.

General

Option Environment variable Default Description
alias IPX_ALIAS {} Map URL prefixes to other prefixes or remote origins.
maxOutputDimension IPX_MAX_OUTPUT_DIMENSION 8192 Maximum width and height (in pixels) of the output image.

Requested width, height and resize dimensions are clamped to maxOutputDimension, preserving the requested aspect ratio, and extend edges are clamped so the extended canvas stays within it. This bounds how much memory a single request can allocate: sharp only limits the input size, so without it /enlarge,s_20000x20000/image.jpg (or /extend_10000_10000_10000_10000/image.jpg) allocates gigabytes from a small source image. Set to false to disable, which is only safe when modifiers come from a trusted source.

Filesystem source (ipxFSStorage)

Enabled by default with the CLI only.

Option Environment variable Default Description
dir IPX_FS_DIR . (current directory) Directory (or directories) files are served from.
maxAge IPX_FS_MAX_AGE 60 (via maxAge) cache-control max-age, in seconds, for served files.
allowSymlinksOutsideDir IPX_FS_ALLOW_SYMLINKS_OUTSIDE_DIR false Allows symlinks inside dir to resolve outside of it.

HTTP(s) source (ipxHttpStorage)

Enabled by default with the CLI only.

Option Environment variable Default Description
domains IPX_HTTP_DOMAINS [] Allowlist of hostnames images can be fetched from.
maxAge IPX_HTTP_MAX_AGE 300
fetchOptions IPX_HTTP_FETCH_OPTIONS {} Passed to fetch().
allowAllDomains IPX_HTTP_ALLOW_ALL_DOMAINS false Disables the allowlist. Unsafe on a public server.
blockPrivateIPs IPX_HTTP_BLOCK_PRIVATE_IPS false Rejects hosts that are, or resolve to, a non-public IP address.

Only http: and https: URLs are allowed (anything else is rejected with 403 IPX_FORBIDDEN_PROTOCOL), and redirects are followed only within the allowlist, up to 3 hops: each redirect target is re-validated and a redirect to a host that is not listed is rejected with 403 IPX_FORBIDDEN_HOST (502 IPX_TOO_MANY_REDIRECTS beyond 3 hops). Previously redirects were followed blindly, which let an allowlisted host with an open redirect bounce IPX to internal addresses such as the cloud metadata service (SSRF). If an allowlisted host redirects to a CDN, add the CDN hostname to the allowlist as well. Redirect re-validation is skipped when allowAllDomains is enabled without blockPrivateIPs (nothing to validate) or when redirect is explicitly set in fetchOptions.

Blocking private IP addresses

The allowlist matches hostnames, not addresses, so an allowlisted host whose DNS record points at 127.0.0.1, 169.254.169.254 or an RFC1918 address is fetched like any other. That is intended — allowlisted domains are trusted — but blockPrivateIPs: true adds a second line of defense for setups where the allowlist is broad (or allowAllDomains is on):

ipxHttpStorage({ domains: ["cdn.example.com"], blockPrivateIPs: true });

The host of the requested URL and of every redirect hop must be a public address. IP literals are checked directly, hostnames are resolved with the OS resolver (dns.lookup, so /etc/hosts counts) and all returned addresses must be public. Loopback, 0.0.0.0/8, RFC1918, CGNAT (100.64.0.0/10), link-local (169.254.0.0/16, fe80::/10), unique-local (fc00::/7), multicast, reserved/test ranges and the IPv4-mapped/compatible IPv6 forms of all of them (::ffff:127.0.0.1 and friends) are rejected with 403 IPX_FORBIDDEN_IP. A host that cannot be resolved is a 502 IPX_DNS_LOOKUP_FAILED, and a runtime without node:net/node:dns a 500 IPX_IP_CHECK_UNAVAILABLE — the check fails closed rather than quietly turning itself off.

It is off by default: many deployments legitimately fetch from in-cluster origins (internal object storage, a sidecar, localhost in development), and enabling it by default would break them.

Two limits to be aware of. It does not close the DNS-rebinding window: the name is resolved again when the socket is opened, so a record with a very short TTL can answer with a public address during validation and a private one during the fetch. Closing that requires pinning the validated address on the connection itself, which the plain fetch used here does not expose. And it is not a substitute for the allowlist — a public address that happens to be reachable is still fetched.

SVG Images

SVG images are not processed by sharp. They are sanitized, optimized with svgo and served as image/svg+xml. Input that is not well-formed XML (an unescaped & is a common cause) is rejected with a 400 IPX_INVALID_SVG.

createIPX({
  storage,
  svg: {
    // SVGO config, or `false` to disable optimization
    optimize: { multipass: true },
    // Serve SVG images unsanitized. Only for fully trusted sources!
    unsafeSkipSanitize: false,
  },
});

Optimization

SVGO's preset-default is applied unless you configure plugins yourself. Output is always a re-serialized document, never byte-identical to the source: ids are renamed, elements that are neither visible nor referenced within the same file are dropped, shapes are converted to paths and <style> rules are inlined.

That is fine for images used as <img src> or as CSS backgrounds, but it can break consumers that reach into the document:

  • Sprite sheets: a <symbol id="icon"> with no <use> in the same file is removed, so <use href="/sprite.svg#icon"> renders nothing.
  • References by id from outside the file, since ids are renamed (icon-home becomes a).
  • Selectors in a host page that inlines the SVG, since <rect> becomes <path> and class-based rules are inlined.

Use svg: { optimize: false } to sanitize without optimizing, or keep optimization with the structural plugins disabled (about 1% larger output for typical icons):

createIPX({
  storage,
  svg: {
    optimize: {
      plugins: [
        {
          name: "preset-default",
          params: {
            overrides: { cleanupIds: false, removeHiddenElems: false },
          },
        },
      ],
    },
  },
});

Sanitization

SVG documents can carry active content, so IPX always sanitizes them before serving. Sanitization is independent of optimization: svg: { optimize: false } only disables SVGO's optimization plugins.

Removed from every SVG:

  • <script> elements (including namespaced ones such as <svg:script>)
  • Event handler attributes (any on* attribute)
  • Embedded foreign documents: <foreignObject>, <iframe>, <embed>, <object>, <base>, <link> and <meta>
  • Event handler elements: <handler> and <listener>
  • SMIL animations (<animate>, <animateMotion>, <animateTransform> and <set>) that assign an on* attribute or an unsafe URI, which could otherwise re-introduce a handler after load
  • URIs (href, xlink:href and src) with a scheme other than http:, https:, mailto:, tel:, ftp: or a non-SVG data:image/* — in particular javascript:, including obfuscated variants using entities or control characters
  • The <!DOCTYPE> declaration and all processing instructions (<?…?>), which are serialized unescaped and can smuggle markup past an HTML parser

External references are kept. Attributes such as <image href="https://…">, <use href="…">, external fonts and @import inside <style> are preserved, since stripping them would break legitimate images. They are not a script execution vector, but they do allow the SVG to make requests to third-party origins (and thereby leak the viewer's IP address) when rendered as a document. If this matters for your threat model, host such images from a separate origin or block the requests with a Content-Security-Policy.

The bundled server sends content-security-policy: default-src 'none' with successful responses by default, which blocks both script execution and external references in browsers that honor it. Custom servers built on the programmatic API should send the same header, since sanitization cannot cover every future browser behavior on its own.

Sanitization can be disabled with svg: { unsafeSkipSanitize: true }. Only do this when every source is fully trusted: IPX will then serve SVG images with XSS payloads unchanged.

Custom URL Style

The parseURL option accepts a function that extracts the resource id and modifiers from the request URL, allowing any URL style you like. It receives the raw (still percent-encoded) request URL, so it is free to decode it however the URL style requires.

Example: modifiers in the filename (/<id>@@<modifiers>.<format>), which can be preferable when prerendering images for static hosting.

import { createIPXFetchHandler, parseIPXURL } from "ipx";

const handler = createIPXFetchHandler(ipx, {
  parseURL(url) {
    const path = decodeURIComponent(new URL(url).pathname.slice(1));

    const match = path.match(/^(.+)@@(.+)\.([^.]+)$/);
    if (!match) {
      // Not our style, fall back to the default `/<modifiers>/<id>`
      return parseIPXURL(url);
    }

    const [, id = "", modifiersString = "", format = ""] = match;
    const modifiers = Object.fromEntries(
      modifiersString.split(",").map((m) => {
        const [key = "", ...values] = m.split("_");
        return [key, values.join("_")];
      }),
    );

    return { id, modifiers: { ...modifiers, format } };
  },
});

// http://localhost:3000/static/buffalo.png@@s_200x200.webp
// http://localhost:3000/static/buffalo.png@@grayscale,w_200.webp

The parser may be async, and can throw an HTTPError (re-exported from ipx) to reject a request with a specific status code.

Returned values are escaped by IPX, so custom parsers don't need to do it themselves. Note this is not an access check — exactly as with the default URL style, what the resulting id is allowed to resolve to is enforced by the storage layer (ipxFSStorage's directory boundary, ipxHttpStorage's domain allowlist).

License

MIT

About

🖼️ High performance, secure and easy-to-use image optimizer.

Topics

Resources

Stars

Watchers

Forks

Releases

Used by

Contributors

Languages