# 🖼️ IPX [![npm version](https://img.shields.io/npm/v/ipx?color=yellow)](https://npmjs.com/package/ipx) [![npm downloads](https://img.shields.io/npm/dm/ipx?color=yellow)](https://npm.chart.dev/ipx) High performance, secure and easy-to-use image optimizer powered by [sharp](https://github.com/lovell/sharp) and [svgo](https://github.com/svg/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](https://github.com/unjs/ipx/tree/v3) for v3 docs, and release notes ([alpha](https://github.com/unjs/ipx/releases/tag/v4.0.0-alpha.1), [beta](https://github.com/unjs/ipx/releases/tag/v4.0.0-beta.1)) if you are upgrading. ## Quick Start Start a server for the images in the current directory with the `ipx` command: ```bash npx ipx serve --dir ./ ``` Using `bun`: ```bash 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](#programmatic-api). ## Image URLs A request URL is a list of modifiers, followed by the id of the source image: ``` // ``` 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](#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 (enlarge_false turns it back off). Without it, the source is never upscaled: a requested box that is too large is shrunk, preserving its aspect ratio, so the output is never larger than the source, except along the padded axis of fit_contain.
/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 ```ts [serve.ts] 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](https://h3.dev) ```ts [h3.ts] 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](https://expressjs.com) ```ts [express.ts] 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. | | `allowedModifiers` | `IPX_ALLOWED_MODIFIERS` | (all) | Modifiers that requests are allowed to use. | 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. `allowedModifiers` restricts a public endpoint to the modifiers a site actually uses, for example `allowedModifiers: ["width", "height", "format", "quality"]` or `IPX_ALLOWED_MODIFIERS=width,height,format,quality`. Aliases follow their modifier (allowing `width` also allows `w`). Any other modifier, including an unknown one, is rejected with a `400` before the source is fetched, so expensive operations (`blur`, `median`, ...) cannot be triggered. Only modifier names are checked, not their values: allowing `format` allows every supported output format, including the slow-to-encode `avif`. The same image also stays reachable under many URLs (`?cb=1`, `w_10` vs `w_1e1`, ...), so this does not prevent busting a CDN cache. ### Server (`createIPXFetchHandler`, `createIPXNodeHandler`, `serveIPX`) | Option | Environment variable | Default | Description | | ------------- | -------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `autoFormats` | `IPX_AUTO_FORMATS` | `avif`, `webp`, `jpeg`, `png`, `tiff`, `heif`, `gif` | Formats `f_auto` can pick from, in order of preference. | `f_auto` serves the format that the client's `accept` header lists with the highest q-value, and equal q-values go to the earlier entry in `autoFormats`. Wildcards such as `image/*` and `*/*` do not match a format, so browsers only negotiate the formats they list explicitly (in practice `avif`, `webp` and `png`). When nothing matches, `jpeg` is served, even if it is not in the list. Animated images only consider `webp` and `gif`, and fall back to `gif`. AVIF compresses best but is much slower to encode, so leave it out when encoding time matters more than size: ```ts createIPXFetchHandler(ipx, { autoFormats: ["webp", "jpeg"] }); ``` With the CLI: `IPX_AUTO_FORMATS=webp,jpeg ipx serve`. Unknown formats throw when the handler is created. ### 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` | `cache-control` max-age, in seconds, when upstream sends none. | | `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 (best effort, see note below). | 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): ```ts 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. > [!WARNING] > `blockPrivateIPs` is best effort and does **not** protect against DNS rebinding. The hostname is resolved once to validate it and again when `fetch` opens the socket, so a DNS record the attacker controls (with a very short TTL) can answer with a public address during validation and a private one during the fetch. This matters when the attacker can choose the hostname: with `allowAllDomains`, or an allowlisted domain whose DNS you do not control. If IPX must not reach internal services, enforce it at the network level: run it behind an egress proxy, or a firewall / network policy that blocks private ranges and the cloud metadata service (on AWS, also require IMDSv2). It is also 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](https://github.com/svg/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`. ```ts 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 `